From bc73db66deba8969bd3b0c102802ce7481ef6581 Mon Sep 17 00:00:00 2001 From: JIANG Date: Wed, 4 Feb 2026 15:37:08 +0800 Subject: [PATCH 001/281] =?UTF-8?q?=E5=8D=87=E7=BA=A7nextjs=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 154 ++++++++++++ next.config.mjs | 8 + package-lock.json | 429 +++++++++++++++++--------------- package.json | 8 +- tsconfig.json | 5 +- 5 files changed, 405 insertions(+), 199 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..f640859 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,154 @@ +# Copilot Instructions for TJWater Frontend + +## Project Overview + +A Next.js 15 + TypeScript water network management system built with Refine framework, featuring real-time hydraulic simulation, SCADA data management, and GIS visualization using OpenLayers and Deck.gl. + +## Build, Test, and Lint Commands + +```bash +# Development +npm run dev # Start dev server (uses 4GB memory allocation) + +# Production +npm run build # Build for production (standalone output) +npm run start # Start production server + +# Testing +npm run test # Run all tests +npm run test:watch # Run tests in watch mode +npm run test:coverage # Generate coverage report + +# Linting +npm run lint # Run ESLint +``` + +**Run single test file:** +```bash +npm test -- path/to/test-file.test.ts +``` + +## Architecture + +### Framework Stack +- **Next.js 15** with App Router (not Pages Router) +- **Refine** framework for admin/CRUD operations +- **NextAuth.js** with Keycloak for SSO authentication +- **Material-UI (MUI) v6** for UI components +- **OpenLayers** + **Deck.gl** for map visualization + +### Route Structure +- `src/app/layout.tsx` - Root layout with RefineContext +- `src/app/(main)/` - Protected routes with shared layout + - `/network-simulation` - Real-time network simulation + - `/scada-data-cleaning` - SCADA data management + - `/monitoring-place-optimization` - Sensor placement optimization + - `/health-risk-analysis` - Health risk assessment + - `/risk-analysis-location` - Risk location analysis + - `/network-partition-optimization` - Network partitioning +- `src/app/OlMap/` - Standalone map route with custom controls +- `src/app/login/` - Public authentication pages + +### Key Directories +- `src/app/_refine_context.tsx` - Refine configuration with resources, auth provider, and data provider +- `src/providers/data-provider/` - REST API data provider (currently mock, update API_URL for production) +- `src/contexts/color-mode/` - Theme switching (light/dark mode persisted in cookies) +- `src/components/` - Reusable UI components (header, loading, olmap, title) +- `src/utils/` - Map utilities (layers.ts, mapQueryService.ts, color parsing) +- `src/config/config.ts` - Environment-based configuration with fallback defaults + +### Path Aliases (TypeScript) +```typescript +@app/* -> src/app/* +@assets/* -> src/assets/* +@components/* -> src/components/* +@config/* -> src/config/* +@contexts/* -> src/contexts/* +@interfaces/* -> src/interfaces/* +@libs/* -> src/libs/* +@providers/* -> src/providers/* +@utils/* -> src/utils/* +@/* -> src/* +``` + +### Map Architecture +The map system uses a hybrid approach: +- **OpenLayers** as the base map engine (vector tiles from GeoServer) +- **Deck.gl** overlays for advanced visualizations (trips, contours, text labels) +- **DeckLayer** custom class bridges OL and Deck.gl (`@utils/layers`) +- Map data sourced from GeoServer MVT tiles (configured in `@config/config.ts`) +- Layers: junctions, pipes, valves, reservoirs, pumps, tanks, scada + +### Client vs Server Components +- Most interactive components use `"use client"` directive (~35 files) +- Map components are always client-side (OpenLayers requires browser APIs) +- Layout and page files without interactivity can be server components + +## Key Conventions + +### Authentication Flow +- Keycloak SSO via NextAuth.js (`src/app/api/auth/[...nextauth]/`) +- Session managed with `SessionProvider` wrapper +- Auth check redirects to `/login` if unauthenticated +- Use `useSession()` hook for current user data + +### Environment Variables +- All frontend-accessible variables must have `NEXT_PUBLIC_` prefix +- Backend URL: `NEXT_PUBLIC_BACKEND_URL` (defaults to http://192.168.1.42:8000) +- GeoServer URL: `NEXT_PUBLIC_MAP_URL` (defaults to http://127.0.0.1:8080/geoserver) +- Map layers: `NEXT_PUBLIC_MAP_AVAILABLE_LAYERS` (comma-separated) +- Keycloak config in `.env.local` (not committed) + +### Refine Resources +Resources defined in `_refine_context.tsx` use Chinese labels and route to pages in `(main)/`: +- Each resource has: name (Chinese), list (route path), meta (icon + label) +- Icons from `react-icons` library +- No CRUD operations defined (list-only pages) + +### Map Styling +- Default styles in `config.MAP_DEFAULT_STYLE` (stroke, circle, colors) +- Circle radius uses zoom-based interpolation (1px at z12, 8px at z24) +- WebGL rendering for vector tiles +- Style legends generated dynamically in map controls + +### TypeScript Configuration +- Strict mode enabled +- Path aliases match jest.config.js mappings +- Target ES5 for broader compatibility +- Incremental builds enabled + +### Next.js Configuration +- **Standalone output** for Docker deployment +- SVG files handled by `@svgr/webpack` (imported as React components) +- No custom server or middleware + +### Testing Setup +- Jest with React Testing Library +- jsdom environment for component testing +- Path aliases configured to match tsconfig.json +- Setup file at `jest.setup.js` + +## Common Patterns + +### Adding a New Route +1. Create directory in `src/app/(main)/your-route/` +2. Add `page.tsx` and optional `loading.tsx` +3. Register resource in `src/app/_refine_context.tsx` resources array +4. Import icon from `react-icons` + +### Working with Maps +- Use `MapComponent` from `src/app/OlMap/MapComponent.tsx` +- Access map context via `useMapData()` hook +- Vector tile layers auto-load from GeoServer workspace +- Custom overlays use Deck.gl layers (TextLayer, TripsLayer, ContourLayer) + +### API Calls +- Update `dataProvider` in `src/providers/data-provider/index.ts` for real backend +- Currently points to `https://api.fake-rest.refine.dev` +- Use Refine hooks (`useList`, `useOne`, etc.) for data fetching + +### Theme Management +- Theme stored in cookies (not localStorage) +- Toggle via `ColorModeContext` from `@contexts/color-mode` +- Supports light/dark modes only +- Default mode read from cookie in root layout (server-side) diff --git a/next.config.mjs b/next.config.mjs index 9f0ce63..427cf11 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + turbopack: { + rules: { + "*.svg": { + loaders: ["@svgr/webpack"], + as: "*.js", + }, + }, + }, webpack(config) { config.module.rules.push({ test: /\.svg$/, diff --git a/package-lock.json b/package-lock.json index f454c4c..3eab56a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "js-cookie": "^3.0.5", - "next": "^15.5.11", + "next": "^16.1.6", "next-auth": "^4.24.5", "ol": "^10.7.0", "postcss": "^8.5.6", @@ -54,9 +54,10 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@types/react-window": "^1.8.8", + "baseline-browser-mapping": "^2.9.19", "cross-env": "^7.0.3", "eslint": "^9.39.2", - "eslint-config-next": "^15.0.3", + "eslint-config-next": "^16.1.6", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "ts-jest": "^29.4.6", @@ -2746,9 +2747,9 @@ "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2778,9 +2779,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -5379,15 +5380,15 @@ } }, "node_modules/@next/env": { - "version": "15.5.11", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.11.tgz", - "integrity": "sha512-g9s5SS9gC7GJCEOR3OV3zqs7C5VddqxP9X+/6BpMbdXRkqsWfFf2CJPBZNvNEtAkKTNuRgRXAgNxSAXzfLdaTg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.4.tgz", - "integrity": "sha512-SR1vhXNNg16T4zffhJ4TS7Xn7eq4NfKfcOsRwea7RIAHrjRpI9ALYbamqIJqkAhowLlERffiwk0FMvTLNdnVtw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5395,9 +5396,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", - "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", "cpu": [ "arm64" ], @@ -5411,9 +5412,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", - "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", "cpu": [ "x64" ], @@ -5427,9 +5428,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", - "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", "cpu": [ "arm64" ], @@ -5443,9 +5444,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", - "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", "cpu": [ "arm64" ], @@ -5459,9 +5460,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", - "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", "cpu": [ "x64" ], @@ -5475,9 +5476,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", - "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", "cpu": [ "x64" ], @@ -5491,9 +5492,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", - "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", "cpu": [ "arm64" ], @@ -5507,9 +5508,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", - "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", "cpu": [ "x64" ], @@ -6289,13 +6290,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", - "dev": true, - "license": "MIT" - }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -9467,21 +9461,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", - "integrity": "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/type-utils": "8.44.1", - "@typescript-eslint/utils": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9491,7 +9484,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.44.1", + "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -9507,17 +9500,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", - "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9532,15 +9525,15 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", - "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.44.1", - "@typescript-eslint/types": "^8.44.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9554,14 +9547,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", - "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9572,9 +9565,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", - "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", "dev": true, "license": "MIT", "engines": { @@ -9589,17 +9582,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz", - "integrity": "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/utils": "8.44.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9614,9 +9607,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", - "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", "engines": { @@ -9628,22 +9621,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", - "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.44.1", - "@typescript-eslint/tsconfig-utils": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9666,36 +9658,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -9713,9 +9675,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -9726,16 +9688,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", - "integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9750,13 +9712,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", - "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -10718,9 +10680,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz", - "integrity": "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -12848,25 +12810,24 @@ } }, "node_modules/eslint-config-next": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.4.tgz", - "integrity": "sha512-BzgVVuT3kfJes8i2GHenC1SRJ+W3BTML11lAOYFOOPzrk2xp66jBOAGEFRw+3LkYCln5UzvFsLhojrshb5Zfaw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.5.4", - "@rushstack/eslint-patch": "^1.10.3", - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@next/eslint-plugin-next": "16.1.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0" + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" }, "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "peerDependenciesMeta": { @@ -12875,6 +12836,19 @@ } } }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -13078,13 +13052,20 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" @@ -13472,9 +13453,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", "funding": [ { "type": "github", @@ -13483,7 +13464,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -14258,13 +14239,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -14442,6 +14416,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -18098,13 +18089,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.11", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.11.tgz", - "integrity": "sha512-L2KPiKmqTDpRdeVDdPjhf43g2/VPe0NCNndq7OKDCgOLWtxe1kbr/zXGIZtYY7kZEAjRf7Bj/mwUFSr+tYC2Yg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", "dependencies": { - "@next/env": "15.5.11", + "@next/env": "16.1.6", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -18113,18 +18105,18 @@ "next": "dist/bin/next" }, "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.7", - "@next/swc-darwin-x64": "15.5.7", - "@next/swc-linux-arm64-gnu": "15.5.7", - "@next/swc-linux-arm64-musl": "15.5.7", - "@next/swc-linux-x64-gnu": "15.5.7", - "@next/swc-linux-x64-musl": "15.5.7", - "@next/swc-win32-arm64-msvc": "15.5.7", - "@next/swc-win32-x64-msvc": "15.5.7", - "sharp": "^0.34.3" + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -21244,9 +21236,9 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "funding": [ { "type": "github", @@ -21688,9 +21680,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -21951,6 +21943,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -22837,6 +22853,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zrender": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", diff --git a/package.json b/package.json index f889c9b..a3f6c50 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "js-cookie": "^3.0.5", - "next": "^15.5.11", + "next": "^16.1.6", "next-auth": "^4.24.5", "ol": "^10.7.0", "postcss": "^8.5.6", @@ -51,6 +51,9 @@ "react-window": "^1.8.10", "tailwindcss": "^4.1.13" }, + "overrides": { + "fast-xml-parser": "5.3.4" + }, "devDependencies": { "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", @@ -62,9 +65,10 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@types/react-window": "^1.8.8", + "baseline-browser-mapping": "^2.9.19", "cross-env": "^7.0.3", "eslint": "^9.39.2", - "eslint-config-next": "^15.0.3", + "eslint-config-next": "^16.1.6", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "ts-jest": "^29.4.6", diff --git a/tsconfig.json b/tsconfig.json index 932ef01..f1900ff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "plugins": [ { "name": "next" @@ -63,7 +63,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" -- 2.54.0 From 9bb0f8dcd77b16a284f3a303ef18af401338f8ca Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 5 Feb 2026 10:50:15 +0800 Subject: [PATCH 002/281] =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E5=85=B3=E9=98=80=E5=88=86=E6=9E=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BurstPipeAnalysisPanel.tsx | 19 +- .../BurstPipeAnalysis/LocationResults.tsx | 39 - .../BurstPipeAnalysis/ValveIsolation.tsx | 1090 +++++++++++------ 3 files changed, 732 insertions(+), 416 deletions(-) diff --git a/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx index 929fa3d..4b00334 100644 --- a/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx +++ b/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useState } from "react"; import { Box, Drawer, @@ -65,9 +65,6 @@ const BurstPipeAnalysisPanel: React.FC = ({ const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); const [panelMode, setPanelMode] = useState("burst"); - const previousMapText = useRef<{ junction?: string; pipe?: string } | null>( - null, - ); const data = useData(); @@ -75,10 +72,6 @@ const BurstPipeAnalysisPanel: React.FC = ({ const [schemes, setSchemes] = useState([]); // 定位结果数据 const [locationResults, setLocationResults] = useState([]); - // 选中的管段ID数组 - const [selectedPipeIds, setSelectedPipeIds] = useState([]); - // 关阀分析状态提升到父组件 - const [valveAnalysisTriggered, setValveAnalysisTriggered] = useState(false); // 关阀分析结果和加载状态 const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false); const [valveAnalysisResult, setValveAnalysisResult] = useState(null); @@ -126,12 +119,6 @@ const BurstPipeAnalysisPanel: React.FC = ({ } }; - const handleAnalyzePipe = (pipeIds: string[]) => { - setSelectedPipeIds(pipeIds); - setValveAnalysisTriggered(true); - setCurrentTab(3); - }; - const drawerWidth = 520; const isBurstMode = panelMode === "burst"; const panelTitle = isBurstMode ? "爆管分析" : "水质模拟"; @@ -308,7 +295,6 @@ const BurstPipeAnalysisPanel: React.FC = ({ {isBurstMode ? ( ) : ( @@ -318,9 +304,6 @@ const BurstPipeAnalysisPanel: React.FC = ({ {isBurstMode && ( setValveAnalysisTriggered(false)} loading={valveAnalysisLoading} result={valveAnalysisResult} onLoadingChange={setValveAnalysisLoading} diff --git a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx index cc5b9d3..e3c2dc7 100644 --- a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx +++ b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx @@ -11,7 +11,6 @@ import { } from "@mui/material"; import { LocationOn as LocationIcon, - Handyman as HandymanIcon, } from "@mui/icons-material"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { useMap } from "@app/OlMap/MapComponent"; @@ -36,12 +35,10 @@ import { LocationResult } from "./types"; interface LocationResultsProps { results?: LocationResult[]; - onAnalyze?: (pipeIds: string[]) => void; } const LocationResults: React.FC = ({ results = [], - onAnalyze, }) => { const [highlightLayer, setHighlightLayer] = useState | null>(null); @@ -349,23 +346,6 @@ const LocationResults: React.FC = ({ 管段列表 - {onAnalyze && ( - - onAnalyze(result.locate_result!)} - color="secondary" - sx={{ - backgroundColor: "rgba(156, 39, 176, 0.1)", - "&:hover": { - backgroundColor: "rgba(156, 39, 176, 0.2)", - }, - }} - > - - - - )} = ({ {pipeId} - {onAnalyze && ( - - { - e.stopPropagation(); - onAnalyze([pipeId]); - }} - className="text-blue-400 hover:text-blue-600" - sx={{ - "&:hover": { - backgroundColor: "rgba(37, 125, 212, 0.1)", - }, - }} - > - - - - )} {/* = ({ - initialPipeIds, + initialPipeIds = [], shouldFetch = false, onFetchComplete, loading: externalLoading, @@ -61,30 +81,93 @@ const ValveIsolation: React.FC = ({ const result = externalResult !== undefined ? externalResult : internalResult; const setLoading = onLoadingChange || setInternalLoading; const setResult = onResultChange || setInternalResult; + + const [selectedPipeId, setSelectedPipeId] = useState(null); + const [highlightFeature, setHighlightFeature] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + const [activeStep, setActiveStep] = useState(0); + const [expandedResult, setExpandedResult] = useState(true); + const [disabledValves, setDisabledValves] = useState([]); + + const { open } = useNotification(); + const map = useMap(); + + const handleMapClick = useCallback( + async (event: any) => { + if (!isSelecting || !map) return; + + const feature = await handleMapClickSelectFeatures(event, map); + if (feature) { + const pipeId = feature.get("id"); + if (pipeId) { + // 确保是管道 + const layerId = feature.getId()?.toString().split(".")[0] || ""; + const isPipe = layerId.includes("pipe") || layerId.includes("Pipe"); + + if (!isPipe) { + open?.({ + type: "error", + message: "请选择管道类型要素", + }); + return; + } + + setSelectedPipeId(pipeId); + setHighlightFeature(feature); + setIsSelecting(false); + setResult(null); // 清除旧结果 + } + } + }, + [isSelecting, map, open, setResult], + ); + + useEffect(() => { + if (!map) return; + if (isSelecting) { + map.on("click", handleMapClick); + } else { + map.un("click", handleMapClick); + } + + return () => { + map.un("click", handleMapClick); + }; + }, [map, isSelecting, handleMapClick]); + + const clearSelectedPipe = () => { + setSelectedPipeId(null); + setHighlightFeature(null); + setHighlightFeatures([]); + setResult?.(null); + setActiveStep(0); + setExpandedResult(false); + setDisabledValves([]); + }; + const [highlightLayer, setHighlightLayer] = useState | null>(null); const [highlightFeatures, setHighlightFeatures] = useState([]); const [highlightType, setHighlightType] = useState< "must_close" | "optional" | "affected_node" | "pipe" >("affected_node"); - const { open } = useNotification(); - const lastPipeIdsRef = useRef(""); - const map = useMap(); - const handleLocatePipes = (pipeIds: string[]) => { + + const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => { if (pipeIds.length > 0) { queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => { if (features.length > 0) { - // 设置高亮类型为管段 - setHighlightType("pipe"); - // 设置高亮要素 - setHighlightFeatures(features); + if (highlight) { + // 设置高亮类型为管段 + setHighlightType("pipe"); + // 设置高亮要素 + setHighlightFeatures(features); + } // 将 OpenLayers Feature 转换为 GeoJSON Feature const geojsonFormat = new GeoJSON(); const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature), ); - const extent = bbox(featureCollection(geojsonFeatures as any)); if (extent) { @@ -168,28 +251,41 @@ const ValveIsolation: React.FC = ({ }; const fetchAnalysis = useCallback( - async (ids: string[]) => { + async (ids: string[], disabled: string[] = []) => { if (!ids || ids.length === 0) { - open?.({ type: "error", message: "请提供管段ID" }); + open?.({ type: "error", message: "请在地图上选择要分析的管段" }); return; } setLoading(true); - setResult(null); + const isExpandSearch = disabled.length > 0; + if (!isExpandSearch) { + setResult(null); + setDisabledValves([]); + } try { + const params: any = { + network: NETWORK_NAME, + accident_element: ids, + }; + if (disabled.length > 0) { + params.disabled_valves = disabled; + } const response = await axios.get( `${config.BACKEND_URL}/api/v1/valve_isolation_analysis/`, { - params: { - network: NETWORK_NAME, - accident_element: ids, - }, + params, paramsSerializer: { - indexes: null, // 生成格式: accident_element=P1&accident_element=P2 + indexes: null, // 生成格式: accident_element=P1&accident_element=P2&disabled_valves=V1&disabled_valves=V2 }, }, ); setResult(response.data); - open?.({ type: "success", message: "分析成功" }); + if (!isExpandSearch) { + setActiveStep(1); + } else { + setActiveStep(2); + } + open?.({ type: "success", message: isExpandSearch ? "扩大搜索成功" : "分析成功" }); } catch (error) { console.error(error); open?.({ @@ -199,36 +295,95 @@ const ValveIsolation: React.FC = ({ }); } finally { setLoading(false); - onFetchComplete?.(); } }, - [open, onFetchComplete], + [open, setLoading, setResult], ); + // 监听外部传入的分析请求 useEffect(() => { - // 只有在明确要求获取数据时才调用 API - if (shouldFetch && initialPipeIds && initialPipeIds.length > 0) { - // 使用排序后的字符串作为唯一标识,避免数组引用变化导致重复调用 - const pipeIdsKey = [...initialPipeIds].sort().join(","); + if (shouldFetch && initialPipeIds.length > 0) { + // 这里简单地取第一个作为 selectedPipeId,实际 fetchAnalysis 支持数组 + setSelectedPipeId(initialPipeIds[0]); - // 只有当 pipeIds 真正改变时才调用 API - if (pipeIdsKey !== lastPipeIdsRef.current) { - lastPipeIdsRef.current = pipeIdsKey; - fetchAnalysis(initialPipeIds); - } else { - // 如果 pipeIds 相同,直接调用完成回调 - onFetchComplete?.(); + // 尝试获取Feature以高亮 (可选) + queryFeaturesByIds(initialPipeIds, "geo_pipes_mat").then((features) => { + if (features && features.length > 0) { + setHighlightFeature(features[0]); + } + }); + + fetchAnalysis(initialPipeIds); + + if (onFetchComplete) { + onFetchComplete(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldFetch, initialPipeIds]); + }, [shouldFetch, initialPipeIds, fetchAnalysis, onFetchComplete]); // 初始化高亮图层 useEffect(() => { if (!map) return; - // 动态样式函数,根据 highlightType 返回不同的样式 + // 动态样式函数,根据 highlightType 和 selectedPipeId 返回不同的样式 const getHighlightStyle = (feature: FeatureLike) => { + // 如果是当前选择的爆管点feature + if (highlightFeature && feature === highlightFeature) { + const styles = []; + // 线条样式(底层发光,主线条,内层高亮线) + styles.push( + new Style({ + stroke: new Stroke({ + color: "rgba(255, 0, 0, 0.3)", + width: 14, + }), + }), + new Style({ + stroke: new Stroke({ + color: "rgba(255, 0, 0, 1)", + width: 7, + lineDash: [15, 10], + }), + }), + new Style({ + stroke: new Stroke({ + color: "rgba(255, 102, 102, 1)", + width: 4, + lineDash: [15, 10], + }), + }), + ); + const geometry = feature.getGeometry(); + const lineCoords = + geometry?.getType() === "LineString" + ? (geometry as any).getCoordinates() + : null; + if (geometry && lineCoords) { + const lineCoordsWGS84 = lineCoords.map((coord: []) => { + const [lon, lat] = toLonLat(coord); + return [lon, lat]; + }); + // 计算中点 + const lineStringFeature = lineString(lineCoordsWGS84); + const lineLength = length(lineStringFeature); + const midPoint = along(lineStringFeature, lineLength / 2).geometry + .coordinates; + // 在中点添加 icon 样式 + const midPointMercator = toMercator(midPoint); + styles.push( + new Style({ + geometry: new Point(midPointMercator), + image: new Icon({ + src: "/icons/burst_pipe.svg", + scale: 0.25, + anchor: [0.5, 1], + }), + }), + ); + } + return styles; + } + if (highlightType === "pipe") { // 管段 - 多层红色线条样式 + 中点图标 const styles = []; @@ -345,7 +500,7 @@ const ValveIsolation: React.FC = ({ return () => { map.removeLayer(highlightLayer); }; - }, [map, highlightType]); + }, [map, highlightType, highlightFeature]); // 高亮要素的函数 useEffect(() => { @@ -358,373 +513,590 @@ const ValveIsolation: React.FC = ({ } // 清除之前的高亮 source.clear(); - // 添加新的高亮要素 + + // 如果有选中的爆管点(pipe),优先添加到source + if (highlightFeature) { + // 设置一个特殊的属性来区分 + highlightFeature.set('isHighlightPipe', true); + source.addFeature(highlightFeature); + } + + // 添加其他高亮要素 highlightFeatures.forEach((feature) => { if (feature instanceof Feature) { source.addFeature(feature); } }); - }, [highlightFeatures, highlightLayer]); - return ( - - {/* Results Section */} - - {loading ? ( - - - 正在分析... + }, [highlightFeatures, highlightLayer, highlightFeature]); + + // 切换不可用阀门的选择状态 + const toggleDisabledValve = (valveId: string) => { + setDisabledValves((prev) => { + if (prev.includes(valveId)) { + return prev.filter((id) => id !== valveId); + } else { + return [...prev, valveId]; + } + }); + }; + + // 渲染结果卡片 + const renderResultCard = (isExpanded: boolean = false, allowSelectDisabled: boolean = false) => { + if (!result) return null; + + return ( + + {/* 状态信息 */} + + + + + {isExpanded ? "扩大搜索结果" : "分析结果"} + + + - ) : result ? ( - - {/* 头部:状态信息 */} - - - - 关阀分析结果 - + + {/* 事故管段 */} + + + + + 目标事故管段 + + {/* {result.accident_elements && result.accident_elements.length > 0 && ( + + handleLocatePipes(result.accident_elements!)} + sx={{ color: "rgb(220, 38, 38)", padding: "2px" }} + > + + + + )} */} + + + {result.accident_elements?.map((pipeId, idx) => ( handleLocatePipes([pipeId], false)} sx={{ + backgroundColor: "rgb(254, 242, 242)", + border: "1px solid rgb(252, 165, 165)", + color: "rgb(185, 28, 28)", fontWeight: 600, - fontSize: "0.75rem", - height: "24px", + "&:hover": { + backgroundColor: "rgb(254, 226, 226)", + borderColor: "rgb(239, 68, 68)", + }, }} /> - - - - - - - 爆管管段 - - - {result.accident_elements && - result.accident_elements.length > 0 && ( - - - handleLocatePipes(result.accident_elements!) - } - sx={{ - backgroundColor: "rgba(255, 0, 0, 0.1)", - "&:hover": { - backgroundColor: "rgba(255, 0, 0, 0.2)", - }, - }} - > - - - - )} - - - {result.accident_elements?.map( - (pipeId: string, idx: number) => ( - handleLocatePipes([pipeId])} - sx={{ - backgroundColor: "rgba(255, 255, 255, 0.9)", - border: "1.5px solid rgb(248, 113, 113)", - color: "rgb(185, 28, 28)", - fontWeight: 600, - fontSize: "0.8rem", - cursor: "pointer", - transition: "all 0.2s", - "&:hover": { - backgroundColor: "rgb(254, 226, 226)", - borderColor: "rgb(220, 38, 38)", - transform: "translateY(-1px)", - boxShadow: "0 2px 4px rgba(220, 38, 38, 0.2)", - }, - }} - /> - ), - )} - - + ))} + - {/* 主要信息:三栏卡片布局 */} - - {/* 必关阀门卡片 */} - - - - - 必关阀门 - - - - {result.must_close_valves?.length || 0} 个 + {/* 统计概览 */} + + {[ + { label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" }, + { label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" }, + { label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" }, + ].map((item, index) => ( + + + {item.value} + + + {item.label} + ))} + + - {/* 可选阀门卡片 */} - - - - - 可选阀门 - - - - {result.optional_valves?.length || 0} 个 - - + {/* 必关阀门选择提示 - 只在流程2显示 */} + {allowSelectDisabled && result.must_close_valves && result.must_close_valves.length > 0 && ( + + + 可选择无法关闭的阀门 + + + 点击下方必关阀门列表中的阀门进行选择,已选择的阀门将在扩大搜索时作为不可用阀门处理 + + + )} - {/* 受影响节点卡片 */} - - - - - 受影响节点 - - - - {result.affected_nodes?.length || 0} 个 - - - + {/* 详细列表 - 可折叠 */} + + - {/* 必须关闭阀门详细列表 */} - {result.must_close_valves && - result.must_close_valves.length > 0 && ( - - - - 必须关闭阀门 + {expandedResult && ( + + {/* 必关阀门 */} + {result.must_close_valves && result.must_close_valves.length > 0 && ( + + + + 必关阀门列表 ({result.must_close_valves.length}) + {allowSelectDisabled && " - 点击勾选不可用阀门"} - + - handleLocateMustCloseValves(result.must_close_valves!) - } - color="error" + onClick={() => handleLocateMustCloseValves(result.must_close_valves!)} sx={{ + color: "rgb(211, 47, 47)", backgroundColor: "rgba(211, 47, 47, 0.1)", "&:hover": { backgroundColor: "rgba(211, 47, 47, 0.2)", }, }} > - + - - {result.must_close_valves.map((valveId, idx) => ( - handleLocateMustCloseValves([valveId])} + + {result.must_close_valves.map((valveId, idx) => { + const isSelected = disabledValves.includes(valveId); + return ( + handleLocateMustCloseValves([valveId])} + sx={{ + "&:active": { + transform: "scale(0.98)", + }, + }} + > + + + {valveId} + + {allowSelectDisabled && ( + { + e.stopPropagation(); + toggleDisabledValve(valveId); + }} + className="cursor-pointer" + > + {isSelected ? ( + + ) : ( + + )} + + )} + + + ); + })} + + + )} + + {/* 可选阀门 */} + {result.optional_valves && result.optional_valves.length > 0 && ( + + + + 可选阀门列表 ({result.optional_valves.length}) + + + handleLocateOptionalValves(result.optional_valves!)} sx={{ - "&:active": { - transform: "scale(0.98)", - boxShadow: "0 1px 2px rgba(211, 47, 47, 0.2)", + color: "rgb(237, 108, 2)", + backgroundColor: "rgba(237, 108, 2, 0.1)", + "&:hover": { + backgroundColor: "rgba(237, 108, 2, 0.2)", }, }} > - - {valveId} - + + + + + + {result.optional_valves.map((valveId, idx) => ( + handleLocateOptionalValves([valveId])} + sx={{ + "&:active": { + transform: "scale(0.98)", + }, + }} + > + + + {valveId} + + ))} )} - {/* 可选关闭阀门详细列表 */} - {result.optional_valves && result.optional_valves.length > 0 && ( - - - - 可选关闭阀门 - - - - handleLocateOptionalValves(result.optional_valves!) - } - color="warning" - sx={{ - backgroundColor: "rgba(237, 108, 2, 0.1)", - "&:hover": { - backgroundColor: "rgba(237, 108, 2, 0.2)", - }, - }} - > - - - - - - {result.optional_valves.map((valveId, idx) => ( - handleLocateOptionalValves([valveId])} - sx={{ - "&:active": { - transform: "scale(0.98)", - boxShadow: "0 1px 2px rgba(237, 108, 2, 0.2)", - }, - }} - > - 0 && ( + + + + 受影响节点 ({result.affected_nodes.length}) + + + handleLocateNodes(result.affected_nodes!)} + sx={{ + color: "rgb(25, 118, 210)", + backgroundColor: "rgba(25, 118, 210, 0.1)", + "&:hover": { + backgroundColor: "rgba(25, 118, 210, 0.2)", + }, + }} > - {valveId} - - - ))} - - - )} - - {/* 受影响节点详细列表 */} - {result.affected_nodes && result.affected_nodes.length > 0 && ( - - - - 受影响节点 - - - handleLocateNodes(result.affected_nodes!)} - color="primary" - sx={{ - backgroundColor: "rgba(37, 125, 212, 0.1)", - "&:hover": { - backgroundColor: "rgba(37, 125, 212, 0.2)", - }, - }} - > - - - - - - {result.affected_nodes.map((nodeId, idx) => ( - handleLocateNodes([nodeId])} - sx={{ - "&:active": { - transform: "scale(0.98)", - boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)", - }, - }} - > - + + + + + {result.affected_nodes.map((nodeId, idx) => ( + handleLocateNodes([nodeId])} + sx={{ + "&:active": { + transform: "scale(0.98)", + }, + }} > - {nodeId} - - - ))} + + + {nodeId} + + + + ))} + - - )} - - ) : ( - - - - - - - - + )} - 暂无关阀分析结果 - - 请先查看定位结果 - - - )} + )} + + + ); + }; + + return ( + + {/* 流程区域 */} + + + {/* 流程1:选择管段并分析 */} + + ( + = 0 + ? "bg-blue-600 text-white" + : "bg-gray-300 text-gray-600" + }`} + > + 1 + + )} + > + + 选择管段并进行分析 + + + + + {/* 选择管段 */} + + + + 选择爆管管段 + + {!isSelecting ? ( + + ) : ( + + )} + + + {isSelecting && ( + + 💡 点击地图上的管道添加爆管点 + + )} + + {selectedPipeId ? ( + + + + {selectedPipeId} + + + 已选择 + + + + + + ) : ( + + 暂未选择管段 + + )} + + + {/* 操作按钮 */} + + + + + + + + {/* 流程2:查看分析结果 */} + + ( + = 1 + ? "bg-blue-600 text-white" + : "bg-gray-300 text-gray-600" + }`} + > + 2 + + )} + > + + 查看分析结果 + + + + + {loading ? ( + + + + 分析计算中,请稍候... + + + ) : result ? ( + <> + {renderResultCard(false, true)} + + {/* 操作按钮 */} + + + + + + ) : ( + + 请先完成流程1的分析操作 + + )} + + + + + {/* 流程3:扩大搜索结果 */} + + ( + = 2 + ? "bg-blue-600 text-white" + : "bg-gray-300 text-gray-600" + }`} + > + 3 + + )} + > + + 扩大搜索结果(可选) + + + + + {loading ? ( + + + + 扩大搜索中,请稍候... + + + ) : activeStep >= 2 && result ? ( + <> + {renderResultCard(true, false)} + + {/* 最终结果提示 */} + {result.isolatable ? ( + + + ✓ 扩大搜索成功,找到可行的隔离方案 + + + 已标记 {disabledValves.length} 个阀门为不可用状态,可以按照上述新的阀门配置进行隔离操作 + + + ) : ( + + + ✗ 扩大搜索后仍无法完全隔离 + + + 即使排除了 {disabledValves.length} 个不可用阀门,仍无法找到有效隔离方案。建议检查管网拓扑结构或阀门配置 + + + )} + + {/* 操作按钮 */} + + + + + + ) : ( + + 请先在流程2中选择不可用阀门,然后点击"扩大搜索"按钮 + + )} + + + + ); -- 2.54.0 From 5b52afcc53cd559ad88eb35788c635a231af86c5 Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 5 Feb 2026 11:56:42 +0800 Subject: [PATCH 003/281] =?UTF-8?q?=E7=88=86=E7=AE=A1=E5=88=86=E6=9E=90?= =?UTF-8?q?=E3=80=81=E6=B0=B4=E8=B4=A8=E6=A8=A1=E6=8B=9F=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=88=86=E7=A6=BB=EF=BC=9B=E8=B0=83=E6=95=B4sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipe-burst-analysis}/loading.tsx | 0 .../pipe-burst-analysis}/page.tsx | 0 .../pipe-flushing/loading.tsx | 5 + .../water-quality-simulation/page.tsx | 16 ++ src/app/_refine_context.tsx | 33 ++- src/components/loading/MapSkeleton.tsx | 137 +++++++----- .../BurstPipeAnalysisPanel.tsx | 110 ++------- .../WaterQualityPanel.tsx | 208 ++++++++++++++++++ 8 files changed, 360 insertions(+), 149 deletions(-) rename src/app/(main)/{risk-analysis-location => hydraulic-simulation/pipe-burst-analysis}/loading.tsx (100%) rename src/app/(main)/{risk-analysis-location => hydraulic-simulation/pipe-burst-analysis}/page.tsx (100%) create mode 100644 src/app/(main)/hydraulic-simulation/pipe-flushing/loading.tsx create mode 100644 src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx create mode 100644 src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx diff --git a/src/app/(main)/risk-analysis-location/loading.tsx b/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/loading.tsx similarity index 100% rename from src/app/(main)/risk-analysis-location/loading.tsx rename to src/app/(main)/hydraulic-simulation/pipe-burst-analysis/loading.tsx diff --git a/src/app/(main)/risk-analysis-location/page.tsx b/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx similarity index 100% rename from src/app/(main)/risk-analysis-location/page.tsx rename to src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx diff --git a/src/app/(main)/hydraulic-simulation/pipe-flushing/loading.tsx b/src/app/(main)/hydraulic-simulation/pipe-flushing/loading.tsx new file mode 100644 index 0000000..2c57921 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/pipe-flushing/loading.tsx @@ -0,0 +1,5 @@ +import { MapSkeleton } from "@components/loading/MapSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx new file mode 100644 index 0000000..a3133fb --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import MapComponent from "@app/OlMap/MapComponent"; +import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel"; + +export default function Home() { + return ( +
+ + + + +
+ ); +} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index 1ac8bf6..d5366d1 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -21,6 +21,7 @@ import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; import { TbLocationPin } from "react-icons/tb"; import { AiOutlinePartition } from "react-icons/ai"; +import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; type RefineContextProps = { defaultMode?: string; @@ -154,11 +155,37 @@ const App = (props: React.PropsWithChildren) => { }, }, { - name: "风险分析定位", - list: "/risk-analysis-location", + name: "Hydraulic Simulation", meta: { + icon: , + label: "水力仿真", + }, + }, + { + name: "爆管分析定位", + list: "/hydraulic-simulation/pipe-burst-analysis", + meta: { + parent: "Hydraulic Simulation", icon: , - label: "风险分析定位", + label: "爆管分析定位", + }, + }, + { + name: "水质模拟", + list: "/hydraulic-simulation/water-quality-simulation", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "水质模拟", + }, + }, + { + name: "管道冲洗", + list: "/hydraulic-simulation/pipe-flushing", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "管道冲洗", }, }, { diff --git a/src/components/loading/MapSkeleton.tsx b/src/components/loading/MapSkeleton.tsx index 6cb2bb8..755d1ca 100644 --- a/src/components/loading/MapSkeleton.tsx +++ b/src/components/loading/MapSkeleton.tsx @@ -1,4 +1,4 @@ -import { Box, Skeleton } from "@mui/material"; +import { Box, Skeleton, CircularProgress } from "@mui/material"; /** * 地图页面骨架屏组件 @@ -26,7 +26,24 @@ export function MapSkeleton() { }} /> - {/* 左侧工具栏骨架 */} + {/* 中央加载指示器 */} + + + + + {/* 左侧工具栏骨架 (垂直) */} - {[1, 2, 3, 4, 5].map((i) => ( + {[1, 2, 3, 4].map((i) => ( ))} - {/* 右侧控制面板骨架 */} + {/* 右侧控制面板骨架 (抽屉式) */} - - - - - + + + {/* 面板内容区块 */} + + + + + + + {[1, 2, 3].map((i) => ( + + + + + + + + ))} + + - {/* 底部时间轴骨架 */} + {/* 底部时间轴/控制条骨架 */} - + + + - {/* 缩放控制骨架 */} + {/* 缩放控制骨架 (右下) */} - - - - - {/* 比例尺骨架 */} - - + + ); diff --git a/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx index 4b00334..6d204cc 100644 --- a/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx +++ b/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx @@ -22,13 +22,9 @@ import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; import LocationResults from "./LocationResults"; import ValveIsolation from "./ValveIsolation"; -import ContaminantAnalysisParameters from "../ContaminantSimulation/AnalysisParameters"; -import ContaminantSchemeQuery from "../ContaminantSimulation/SchemeQuery"; -import ContaminantResultsPanel from "../ContaminantSimulation/ResultsPanel"; import axios from "axios"; import { config } from "@config/config"; import { useNotification } from "@refinedev/core"; -import { useData } from "@app/OlMap/MapComponent"; import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types"; interface TabPanelProps { @@ -56,17 +52,12 @@ interface BurstPipeAnalysisPanelProps { onToggle?: () => void; } -type PanelMode = "burst" | "contaminant"; - const BurstPipeAnalysisPanel: React.FC = ({ open: controlledOpen, onToggle, }) => { const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); - const [panelMode, setPanelMode] = useState("burst"); - - const data = useData(); // 持久化方案查询结果 const [schemes, setSchemes] = useState([]); @@ -92,16 +83,6 @@ const BurstPipeAnalysisPanel: React.FC = ({ setCurrentTab(newValue); }; - const handleModeChange = (_event: React.SyntheticEvent, newMode: PanelMode) => { - setPanelMode(newMode); - // 切换模式时,如果当前标签索引超出新模式的标签数量,重置为第一个标签 - // 爆管分析有4个标签(0-3),水质模拟有3个标签(0-2) - const maxTabIndex = newMode === "burst" ? 3 : 2; - if (currentTab > maxTabIndex) { - setCurrentTab(0); - } - }; - const handleLocateScheme = async (scheme: SchemeRecord) => { try { const response = await axios.get( @@ -120,8 +101,7 @@ const BurstPipeAnalysisPanel: React.FC = ({ }; const drawerWidth = 520; - const isBurstMode = panelMode === "burst"; - const panelTitle = isBurstMode ? "爆管分析" : "水质模拟"; + const panelTitle = "爆管分析"; return ( <> @@ -197,32 +177,6 @@ const BurstPipeAnalysisPanel: React.FC = ({ - {/* Tabs 导航 */} - - - - - - = ({ } iconPosition="start" - label={isBurstMode ? "定位结果" : "模拟结果"} + label="定位结果" + /> + } + iconPosition="start" + label="关阀分析" /> - {isBurstMode && ( - } - iconPosition="start" - label="关阀分析" - /> - )} {/* Tab 内容 */} - {isBurstMode ? ( - - ) : ( - - )} + - {isBurstMode ? ( - - ) : ( - setCurrentTab(2)} /> - )} + - {isBurstMode ? ( - - ) : ( - - )} + - {isBurstMode && ( - - - - )} + + + diff --git a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx new file mode 100644 index 0000000..9450e9f --- /dev/null +++ b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx @@ -0,0 +1,208 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, + Drawer, + Tabs, + Tab, + Typography, + IconButton, + Tooltip, +} from "@mui/material"; +import { + ChevronRight, + ChevronLeft, + Analytics as AnalyticsIcon, + Search as SearchIcon, + MyLocation as MyLocationIcon, +} from "@mui/icons-material"; +import ContaminantAnalysisParameters from "./AnalysisParameters"; +import ContaminantSchemeQuery from "./SchemeQuery"; +import ContaminantResultsPanel from "./ResultsPanel"; +import { useData } from "@app/OlMap/MapComponent"; + +interface WaterQualityPanelProps { + open?: boolean; + onToggle?: () => void; +} + +const WaterQualityPanel: React.FC = ({ + open: controlledOpen, + onToggle, +}) => { + const [internalOpen, setInternalOpen] = useState(true); + const [currentTab, setCurrentTab] = useState(0); + + const data = useData(); + + // 使用受控或非受控状态 + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const handleToggle = () => { + if (onToggle) { + onToggle(); + } else { + setInternalOpen(!internalOpen); + } + }; + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setCurrentTab(newValue); + }; + + const drawerWidth = 520; + const panelTitle = "水质模拟"; + + return ( + <> + {/* 收起时的触发按钮 */} + {!isOpen && ( + + + + + {panelTitle} + + + + + )} + + {/* 主面板 */} + + + {/* 头部 */} + + + + + {panelTitle} + + + + + + + + + + + + } + iconPosition="start" + label="分析要件" + /> + } + iconPosition="start" + label="方案查询" + /> + {/* } + iconPosition="start" + label="模拟结果" + /> */} + + + + {/* Tab 内容 */} + + + + + + setCurrentTab(2)} /> + + + + + + + + + ); +}; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel: React.FC = ({ children, value, index }) => { + return ( + + ); +}; + +export default WaterQualityPanel; -- 2.54.0 From 4bd7b48bcf583a545423cca40ea909d74725bdc1 Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 5 Feb 2026 11:56:54 +0800 Subject: [PATCH 004/281] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f640859..a8cc01f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## Project Overview -A Next.js 15 + TypeScript water network management system built with Refine framework, featuring real-time hydraulic simulation, SCADA data management, and GIS visualization using OpenLayers and Deck.gl. +A Next.js 16 + TypeScript water network management system built with Refine framework, featuring real-time hydraulic simulation, SCADA data management, and GIS visualization using OpenLayers and Deck.gl. ## Build, Test, and Lint Commands @@ -24,6 +24,7 @@ npm run lint # Run ESLint ``` **Run single test file:** + ```bash npm test -- path/to/test-file.test.ts ``` @@ -31,13 +32,15 @@ npm test -- path/to/test-file.test.ts ## Architecture ### Framework Stack -- **Next.js 15** with App Router (not Pages Router) + +- **Next.js 16** with App Router (not Pages Router) - **Refine** framework for admin/CRUD operations - **NextAuth.js** with Keycloak for SSO authentication - **Material-UI (MUI) v6** for UI components - **OpenLayers** + **Deck.gl** for map visualization ### Route Structure + - `src/app/layout.tsx` - Root layout with RefineContext - `src/app/(main)/` - Protected routes with shared layout - `/network-simulation` - Real-time network simulation @@ -50,6 +53,7 @@ npm test -- path/to/test-file.test.ts - `src/app/login/` - Public authentication pages ### Key Directories + - `src/app/_refine_context.tsx` - Refine configuration with resources, auth provider, and data provider - `src/providers/data-provider/` - REST API data provider (currently mock, update API_URL for production) - `src/contexts/color-mode/` - Theme switching (light/dark mode persisted in cookies) @@ -58,6 +62,7 @@ npm test -- path/to/test-file.test.ts - `src/config/config.ts` - Environment-based configuration with fallback defaults ### Path Aliases (TypeScript) + ```typescript @app/* -> src/app/* @assets/* -> src/assets/* @@ -72,7 +77,9 @@ npm test -- path/to/test-file.test.ts ``` ### Map Architecture + The map system uses a hybrid approach: + - **OpenLayers** as the base map engine (vector tiles from GeoServer) - **Deck.gl** overlays for advanced visualizations (trips, contours, text labels) - **DeckLayer** custom class bridges OL and Deck.gl (`@utils/layers`) @@ -80,6 +87,7 @@ The map system uses a hybrid approach: - Layers: junctions, pipes, valves, reservoirs, pumps, tanks, scada ### Client vs Server Components + - Most interactive components use `"use client"` directive (~35 files) - Map components are always client-side (OpenLayers requires browser APIs) - Layout and page files without interactivity can be server components @@ -87,12 +95,14 @@ The map system uses a hybrid approach: ## Key Conventions ### Authentication Flow + - Keycloak SSO via NextAuth.js (`src/app/api/auth/[...nextauth]/`) - Session managed with `SessionProvider` wrapper - Auth check redirects to `/login` if unauthenticated - Use `useSession()` hook for current user data ### Environment Variables + - All frontend-accessible variables must have `NEXT_PUBLIC_` prefix - Backend URL: `NEXT_PUBLIC_BACKEND_URL` (defaults to http://192.168.1.42:8000) - GeoServer URL: `NEXT_PUBLIC_MAP_URL` (defaults to http://127.0.0.1:8080/geoserver) @@ -100,29 +110,35 @@ The map system uses a hybrid approach: - Keycloak config in `.env.local` (not committed) ### Refine Resources + Resources defined in `_refine_context.tsx` use Chinese labels and route to pages in `(main)/`: + - Each resource has: name (Chinese), list (route path), meta (icon + label) - Icons from `react-icons` library - No CRUD operations defined (list-only pages) ### Map Styling + - Default styles in `config.MAP_DEFAULT_STYLE` (stroke, circle, colors) - Circle radius uses zoom-based interpolation (1px at z12, 8px at z24) - WebGL rendering for vector tiles - Style legends generated dynamically in map controls ### TypeScript Configuration + - Strict mode enabled - Path aliases match jest.config.js mappings - Target ES5 for broader compatibility - Incremental builds enabled ### Next.js Configuration + - **Standalone output** for Docker deployment - SVG files handled by `@svgr/webpack` (imported as React components) - No custom server or middleware ### Testing Setup + - Jest with React Testing Library - jsdom environment for component testing - Path aliases configured to match tsconfig.json @@ -131,23 +147,27 @@ Resources defined in `_refine_context.tsx` use Chinese labels and route to pages ## Common Patterns ### Adding a New Route + 1. Create directory in `src/app/(main)/your-route/` 2. Add `page.tsx` and optional `loading.tsx` 3. Register resource in `src/app/_refine_context.tsx` resources array 4. Import icon from `react-icons` ### Working with Maps + - Use `MapComponent` from `src/app/OlMap/MapComponent.tsx` - Access map context via `useMapData()` hook - Vector tile layers auto-load from GeoServer workspace - Custom overlays use Deck.gl layers (TextLayer, TripsLayer, ContourLayer) ### API Calls + - Update `dataProvider` in `src/providers/data-provider/index.ts` for real backend - Currently points to `https://api.fake-rest.refine.dev` - Use Refine hooks (`useList`, `useOne`, etc.) for data fetching ### Theme Management + - Theme stored in cookies (not localStorage) - Toggle via `ColorModeContext` from `@contexts/color-mode` - Supports light/dark modes only -- 2.54.0 From f89e43eee2c718e40cefced90e08673bbc1ab8e2 Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 5 Feb 2026 11:59:23 +0800 Subject: [PATCH 005/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E5=86=B2=E6=B4=97=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hydraulic-simulation/pipe-flushing/page.tsx | 14 ++++++++++++++ .../water-quality-simulation/loading.tsx | 5 +++++ 2 files changed, 19 insertions(+) create mode 100644 src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx create mode 100644 src/app/(main)/hydraulic-simulation/water-quality-simulation/loading.tsx diff --git a/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx b/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx new file mode 100644 index 0000000..cf12b8b --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import MapComponent from "@app/OlMap/MapComponent"; +import MapToolbar from "@app/OlMap/Controls/Toolbar"; + +export default function Home() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/(main)/hydraulic-simulation/water-quality-simulation/loading.tsx b/src/app/(main)/hydraulic-simulation/water-quality-simulation/loading.tsx new file mode 100644 index 0000000..2c57921 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/water-quality-simulation/loading.tsx @@ -0,0 +1,5 @@ +import { MapSkeleton } from "@components/loading/MapSkeleton"; + +export default function Loading() { + return ; +} -- 2.54.0 From 4fbe8450151ade860c762e149f298a8c05054b4b Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 5 Feb 2026 17:38:23 +0800 Subject: [PATCH 006/281] =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E5=86=B2=E6=B4=97=E5=8A=9F=E8=83=BD=E9=A1=B5=E9=9D=A2=EF=BC=9B?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=B0=B4=E8=B4=A8=E6=A8=A1=E6=8B=9F=E9=BB=98?= =?UTF-8?q?=E8=AE=A4pattern=EF=BC=9B=E8=B0=83=E6=95=B4sidebar=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E5=90=8D=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipe-flushing/page.tsx | 2 + src/app/_refine_context.tsx | 2 +- .../BurstPipeAnalysis/AnalysisParameters.tsx | 2 +- .../BurstPipeAnalysis/ValveIsolation.tsx | 2 +- .../AnalysisParameters.tsx | 6 +- .../FlushingAnalysis/AnalysisParameters.tsx | 451 ++++++++++++++ .../FlushingAnalysisPanel.tsx | 194 ++++++ .../olmap/FlushingAnalysis/SchemeQuery.tsx | 584 ++++++++++++++++++ .../olmap/FlushingAnalysis/types.ts | 27 + 9 files changed, 1264 insertions(+), 6 deletions(-) create mode 100644 src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx create mode 100644 src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx create mode 100644 src/components/olmap/FlushingAnalysis/SchemeQuery.tsx create mode 100644 src/components/olmap/FlushingAnalysis/types.ts diff --git a/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx b/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx index cf12b8b..0ff6b53 100644 --- a/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx +++ b/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx @@ -2,12 +2,14 @@ import MapComponent from "@app/OlMap/MapComponent"; import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"; export default function Home() { return (
+
); diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index d5366d1..170030f 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -158,7 +158,7 @@ const App = (props: React.PropsWithChildren) => { name: "Hydraulic Simulation", meta: { icon: , - label: "水力仿真", + label: "水力模拟", }, }, { diff --git a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx index 37d0ea4..16bff99 100644 --- a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx @@ -381,7 +381,7 @@ const AnalysisParameters: React.FC = () => { key={pipe.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded" > - + {pipe.id} diff --git a/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx b/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx index 51962f0..765a8dd 100644 --- a/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx +++ b/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx @@ -908,7 +908,7 @@ const ValveIsolation: React.FC = ({ {selectedPipeId ? ( - + {selectedPipeId} diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index 560ef75..d64035d 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -181,7 +181,7 @@ const AnalysisParameters: React.FC = () => { source: sourceNode, concentration, duration, - pattern: pattern || undefined, + pattern: pattern || "CONSTANT", scheme_name: schemeName, }; @@ -276,7 +276,7 @@ const AnalysisParameters: React.FC = () => { {sourceNode ? ( - + {sourceNode} @@ -373,7 +373,7 @@ const AnalysisParameters: React.FC = () => { size="small" value={pattern} onChange={(e) => setPattern(e.target.value)} - placeholder="可选,输入 pattern 名称" + placeholder="可选,输入 pattern 名称,默认为 CONSTANT" /> diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx new file mode 100644 index 0000000..438d684 --- /dev/null +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -0,0 +1,451 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + Box, + TextField, + Button, + Typography, + IconButton, + Stack, + Alert, + Divider, +} from "@mui/material"; +import { Close as CloseIcon } from "@mui/icons-material"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import "dayjs/locale/zh-cn"; +import dayjs, { Dayjs } from "dayjs"; +import { useMap } from "@app/OlMap/MapComponent"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Style, Stroke, Fill, Circle as CircleStyle } from "ol/style"; +import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; +import Feature, { FeatureLike } from "ol/Feature"; +import { useNotification } from "@refinedev/core"; +import axios from "axios"; +import { config, NETWORK_NAME } from "@/config/config"; + +interface ValveItem { + id: string; + k: number; + feature?: any; +} + +const AnalysisParameters: React.FC = () => { + const map = useMap(); + const { open } = useNotification(); + + // State + const [schemeName, setSchemeName] = useState( + "Flushing_" + new Date().getTime(), + ); + const [valves, setValves] = useState([]); + const [drainageNode, setDrainageNode] = useState(null); + const [drainageFeature, setDrainageFeature] = useState(null); + + const [startTime, setStartTime] = useState(dayjs(new Date())); + const [flushFlow, setFlushFlow] = useState(0); + const [duration, setDuration] = useState(3600); + + const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none'); + const [analyzing, setAnalyzing] = useState(false); + + const [highlightLayer, setHighlightLayer] = useState | null>(null); + + // Initialize highlight layer + useEffect(() => { + if (!map) return; + + const highlightStyle = function (feature: FeatureLike) { + const styles = []; + const type = feature.get("type"); // We will set this property when adding to source + + if (type === "valve") { + styles.push( + new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: "rgba(255, 165, 0, 0.8)" }), // Orange for valves + stroke: new Stroke({ color: "white", width: 2 }), + }), + }) + ); + } else if (type === "drainage") { + styles.push( + new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: "rgba(0, 0, 255, 0.8)" }), // Blue for drainage + stroke: new Stroke({ color: "white", width: 2 }), + }), + }) + ); + } + return styles; + }; + + const layer = new VectorLayer({ + source: new VectorSource(), + style: highlightStyle, + zIndex: 1000, + properties: { + name: "FlushingHighlight", + }, + }); + + map.addLayer(layer); + setHighlightLayer(layer); + + return () => { + map.removeLayer(layer); + map.un("click", handleMapClickSelectFeatures); + }; + }, [map]); + + // Update highlight layer features + useEffect(() => { + if (!highlightLayer) return; + const source = highlightLayer.getSource(); + if (!source) return; + + source.clear(); + + // Add valves + valves.forEach((v) => { + if (v.feature) { + const f = v.feature.clone(); // Clone to avoid modifying original + f.set("type", "valve"); + // Ensure geometry is present (it should be for features from map) + if (f.getGeometry()) { + source.addFeature(f); + } + } + }); + + // Add drainage node + if (drainageFeature) { + const f = drainageFeature.clone(); + f.set("type", "drainage"); + source.addFeature(f); + } + + }, [highlightLayer, valves, drainageFeature]); + + // Map click handler + const handleMapClickSelectFeatures = useCallback( + async (event: { coordinate: number[] }) => { + if (!map || selectionMode === 'none') return; + + const feature = await mapClickSelectFeatures(event, map); + if (!feature) return; + + const layer = feature.getId()?.toString().split(".")[0]; + const featureId = feature.getProperties().id; + + if (selectionMode === 'valve') { + if (layer !== 'geo_valves') { + open?.({ + type: "error", + message: "请选择阀门要素", + }); + return; + } + + setValves((prev) => { + if (prev.some((v) => v.id === featureId)) { + open?.({ + type: "error", + message: "该阀门已添加", + }); + return prev; + } + return [...prev, { id: featureId, k: 1.0, feature }]; // Default k=1.0? User can change. + }); + + } else if (selectionMode === 'drainage') { + if (layer !== 'geo_junctions') { + open?.({ + type: "error", + message: "请选择节点要素作为排水点", + }); + return; + } + setDrainageNode(featureId); + setDrainageFeature(feature); + setSelectionMode('none'); // Auto exit selection after picking one + map.un("click", handleMapClickSelectFeatures); + } + }, + [map, selectionMode, open] + ); + + // Bind click event based on selection mode + useEffect(() => { + if (!map || selectionMode === "none") return; + + map.on("click", handleMapClickSelectFeatures); + + return () => { + map.un("click", handleMapClickSelectFeatures); + }; + }, [map, selectionMode, handleMapClickSelectFeatures]); + + // Toggle selection + const toggleSelection = (mode: 'valve' | 'drainage') => { + // If clicking same mode, turn off + if (selectionMode === mode) { + setSelectionMode('none'); + } else { + setSelectionMode(mode); + } + }; + + const handleRemoveValve = (id: string) => { + setValves((prev) => prev.filter((v) => v.id !== id)); + }; + + const handleValveKChange = (id: string, k: string) => { + const numK = parseFloat(k); + setValves(prev => prev.map(v => v.id === id ? { ...v, k: isNaN(numK) ? 0 : numK } : v)); + }; + + const handleAnalyze = async () => { + if (!startTime || !drainageNode || !schemeName.trim()) { + open?.({ + type: "error", + message: "请填写完整参数", + description: "方案名称、开始时间和排水点为必填项", + }); + return; + } + + setAnalyzing(true); + + try { + const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00"); + + const params = { + scheme_name: schemeName, + network: NETWORK_NAME, + start_time: formattedTime, + valves: valves.map(v => v.id), + valves_k: valves.map(v => v.k), + drainage_node_ID: drainageNode, + flush_flow: flushFlow, + duration: duration + }; + + // Use params serializer to handle array params correctly if needed, + // but axios usually handles array as valves[]=1&valves[]=2 + // FastAPI default expects repeated query params. + + const response = await axios.get(`${config.BACKEND_URL}/flushing_analysis/`, { + params, + // Ensure arrays are sent as repeated keys: valves=1&valves=2 + paramsSerializer: { + indexes: null // Result: valves=1&valves=2 + } + }); + if (response.status !== 200) { + throw new Error(`分析请求失败,状态码: ${response.status}`); + } + open?.({ + type: "success", + message: "方案分析成功", + description: "管道冲洗模拟完成,请在方案查询中查看结果。", + }); + } catch (error) { + console.error("提交分析失败", error); + open?.({ + type: "error", + message: "提交分析失败", + description: error instanceof Error ? error.message : "未知错误", + }); + } finally { + setAnalyzing(false); + } + }; + + return ( + + {/* 1. Valve Selection */} + + + + 参与阀门 + + + + {selectionMode === 'valve' && ( + + 点击地图上的阀门进行添加 + + )} + + {valves.map((valve) => ( + + {valve.id} + handleValveKChange(valve.id, e.target.value)} + className="w-20" + slotProps={{ htmlInput: { step: 0.1, min: 0, max: 1 } }} + /> + handleRemoveValve(valve.id)}> + + + + ))} + {valves.length === 0 && ( + + 暂无选中阀门 + + )} + + + + + + {/* 2. Drainage Node Selection */} + + + + 排水节点 + + + + {selectionMode === 'drainage' && ( + + 点击地图上的节点作为排水点 + + )} + + {drainageNode && ( + + {drainageNode} + { + setDrainageNode(null); + setDrainageFeature(null); + }} + > + + + + )} + {!drainageNode && ( + + 暂无选中排水节点 + + )} + + + + + + {/* 3. Parameters */} + + + + 开始时间 + + + setStartTime(newValue)} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + localeText={ + pickerZhCN.components.MuiLocalizationProvider.defaultProps + .localeText + } + /> + + + + {/* Scheme Name */} + + + 方案名称 + + setSchemeName(e.target.value)} + placeholder="请输入方案名称" + /> + + + + + + 冲洗流量 + + setFlushFlow(parseFloat(e.target.value) || 0)} + /> + + + + 持续时长 (秒) + + setDuration(parseInt(e.target.value) || 0)} + /> + + + + + + + + + ); +}; + +export default AnalysisParameters; diff --git a/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx b/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx new file mode 100644 index 0000000..83523bc --- /dev/null +++ b/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx @@ -0,0 +1,194 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, + Drawer, + Tabs, + Tab, + Typography, + IconButton, + Tooltip, +} from "@mui/material"; +import { + ChevronRight, + ChevronLeft, + Analytics as AnalyticsIcon, + Search as SearchIcon, +} from "@mui/icons-material"; +import { MdCleaningServices } from "react-icons/md"; +import AnalysisParameters from "./AnalysisParameters"; +import SchemeQuery from "./SchemeQuery"; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel: React.FC = ({ children, value, index }) => { + return ( + + ); +}; + +interface FlushingAnalysisPanelProps { + open?: boolean; + onToggle?: () => void; +} + +const FlushingAnalysisPanel: React.FC = ({ + open: controlledOpen, + onToggle, +}) => { + const [internalOpen, setInternalOpen] = useState(true); + const [currentTab, setCurrentTab] = useState(0); + + // Using controlled or uncontrolled state + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const handleToggle = () => { + if (onToggle) { + onToggle(); + } else { + setInternalOpen(!internalOpen); + } + }; + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setCurrentTab(newValue); + }; + + const drawerWidth = 450; // Slightly narrower than burst analysis as we have fewer tabs + const panelTitle = "管道冲洗分析"; + + return ( + <> + {/* Toggle Button when closed */} + {!isOpen && ( + + + + + {panelTitle} + + + + + )} + + {/* Main Panel */} + + + {/* Header */} + + + + + {panelTitle} + + + + + + + + + + + + } + iconPosition="start" + label="分析参数" + /> + } + iconPosition="start" + label="方案查询" + /> + + + + {/* Tab Content */} + + + + + + + + + + + ); +}; + +export default FlushingAnalysisPanel; diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx new file mode 100644 index 0000000..eb75457 --- /dev/null +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -0,0 +1,584 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import ReactDOM from "react-dom"; + +import { + Box, + Button, + Typography, + Checkbox, + FormControlLabel, + IconButton, + Card, + CardContent, + Chip, + Tooltip, + Collapse, + Link, +} from "@mui/material"; +import { + Info as InfoIcon, + Search as SearchIcon, + LocationOn as LocationIcon, +} from "@mui/icons-material"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import "dayjs/locale/zh-cn"; +import dayjs, { Dayjs } from "dayjs"; +import axios from "axios"; +import moment from "moment"; +import { config, NETWORK_NAME } from "@config/config"; +import { useNotification } from "@refinedev/core"; +import { useData, useMap } from "@app/OlMap/MapComponent"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { GeoJSON } from "ol/format"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Style, Icon, Circle, Fill, Stroke } from "ol/style"; +import Feature, { FeatureLike } from "ol/Feature"; +import { bbox, featureCollection } from "@turf/turf"; +import Timeline from "@app/OlMap/Controls/Timeline"; +import { SchemeRecord, SchemaItem } from "./types"; + +interface SchemeQueryProps { + schemes?: SchemeRecord[]; + onSchemesChange?: (schemes: SchemeRecord[]) => void; + network?: string; +} + +const SCHEME_TYPE = "flushing_analysis"; + +const SchemeQuery: React.FC = ({ + schemes: externalSchemes, + onSchemesChange, + network = NETWORK_NAME, +}) => { + const [queryAll, setQueryAll] = useState(true); + const [queryDate, setQueryDate] = useState(dayjs(new Date())); + const [internalSchemes, setInternalSchemes] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState(null); + + const [highlightLayer, setHighlightLayer] = + useState | null>(null); + const [highlightFeatures, setHighlightFeatures] = useState([]); + + // Timeline related state + const [showTimeline, setShowTimeline] = useState(false); + const [selectedDate, setSelectedDate] = useState(undefined); + const [timeRange, setTimeRange] = useState<{ start: Date; end: Date } | undefined>(); + const [mapContainer, setMapContainer] = useState(null); + + const { open } = useNotification(); + const map = useMap(); + const data = useData(); + const { schemeName, setSchemeName } = data || {}; + + const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; + const setSchemes = onSchemesChange || setInternalSchemes; + + useEffect(() => { + if (!map) return; + const target = map.getTargetElement(); + if (target) { + setMapContainer(target); + } + }, [map]); + + // Initialize highlight layer + useEffect(() => { + if (!map) return; + + const themeColor = "rgba(0, 0, 255"; // Blue for drainage + const valveColor = "rgba(255, 165, 0"; // Orange for valves + + const sourceStyle = function (feature: FeatureLike) { + const type = (feature as any).get("type"); + if (type === "valve") { + return [ + new Style({ + image: new Circle({ + radius: 8, + fill: new Fill({ color: `${valveColor}, 0.8)` }), + stroke: new Stroke({ color: "white", width: 2 }), + }), + }) + ]; + } else { + // Default drainage + return [ + new Style({ + image: new Circle({ + radius: 12, + fill: new Fill({ color: `${themeColor}, 0.2)` }), + }), + }), + new Style({ + image: new Circle({ + radius: 8, + stroke: new Stroke({ color: `${themeColor}, 0.5)`, width: 2 }), + fill: new Fill({ color: `${themeColor}, 0.3)` }), + }), + }), + new Style({ + image: new Circle({ + radius: 4, + fill: new Fill({ color: `${themeColor}, 1)` }), + stroke: new Stroke({ color: "white", width: 1 }), + }) + }), + ]; + } + }; + + const layer = new VectorLayer({ + source: new VectorSource(), + style: sourceStyle, + zIndex: 1000, + properties: { + name: "FlushingQueryResultHighlight", + }, + }); + + map.addLayer(layer); + setHighlightLayer(layer); + + return () => { + map.removeLayer(layer); + }; + }, [map]); + + // Update highlight features + useEffect(() => { + if (!highlightLayer) return; + const source = highlightLayer.getSource(); + if (!source) return; + + source.clear(); + highlightFeatures.forEach((feature) => { + if (feature instanceof Feature) { + source.addFeature(feature); + } + }); + }, [highlightFeatures, highlightLayer]); + + const handleLocateDrainageNode = (nodeId: string) => { + if (!nodeId) return; + queryFeaturesByIds([nodeId], "geo_junctions_mat").then((features) => { + if (features.length > 0) { + // Add type property to distinguish styling + features.forEach(f => f.set("type", "drainage")); + setHighlightFeatures(features); + zoomToFeatures(features); + } else { + open?.({ + type: "error", + message: "未找到该节点要素", + }); + } + }); + }; + + const handleLocateValves = (valveIds: string[]) => { + if (!valveIds || valveIds.length === 0) return; + queryFeaturesByIds(valveIds, "geo_valves").then((features) => { + if (features.length > 0) { + features.forEach(f => f.set("type", "valve")); + setHighlightFeatures(features); + zoomToFeatures(features); + } else { + open?.({ + type: "error", + message: "未找到阀门要素", + }); + } + }); + }; + + const zoomToFeatures = (features: Feature[]) => { + const geojsonFormat = new GeoJSON(); + const geojsonFeatures = features.map((feature) => + geojsonFormat.writeFeatureObject(feature), + ); + const extent = bbox(featureCollection(geojsonFeatures as any)); + if (extent) { + map?.getView().fit(extent, { + maxZoom: 18, + duration: 1000, + padding: [50, 50, 50, 50], + }); + } + }; + + const formatTime = (timeStr: string) => { + return moment(timeStr).format("MM-DD HH:mm"); + }; + + const handleQuery = async () => { + if (!queryAll && !queryDate) return; + + setLoading(true); + try { + const response = await axios.get( + `${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`, + ); + + let filteredResults = response.data; + + // Filter by type + filteredResults = filteredResults.filter((item: SchemaItem) => item.scheme_type === SCHEME_TYPE); + + if (!queryAll && queryDate) { + const formattedDate = queryDate.format("YYYY-MM-DD"); + filteredResults = filteredResults.filter((item: SchemaItem) => { + const itemDate = moment(item.create_time).format("YYYY-MM-DD"); + return itemDate === formattedDate; + }); + } + + setSchemes( + filteredResults.map((item: SchemaItem) => ({ + id: item.scheme_id, + schemeName: item.scheme_name, + type: item.scheme_type, + user: item.username, + create_time: item.create_time, + startTime: item.scheme_start_time, + schemeDetail: item.scheme_detail, + })), + ); + + if (filteredResults.length === 0) { + open?.({ + type: "error", + message: "未找到相关方案", + description: "请尝试更改查询条件", + }); + } + } catch (error) { + console.error("查询请求失败:", error); + open?.({ + type: "error", + message: "查询失败", + description: "获取方案列表失败,请稍后重试", + }); + } finally { + setLoading(false); + } + }; + + const handleViewResults = (scheme: SchemeRecord) => { + setShowTimeline(true); + + const schemeDate = scheme.startTime ? new Date(scheme.startTime) : undefined; + + if (scheme.startTime && scheme.schemeDetail?.duration) { + const start = new Date(scheme.startTime); + const end = new Date(start.getTime() + scheme.schemeDetail.duration * 1000); + setSelectedDate(schemeDate); + setTimeRange({ start, end }); + } + + setSchemeName?.(scheme.schemeName); + + // Locate drainage node by default if available + if (scheme.schemeDetail?.drainage_node_ID) { + handleLocateDrainageNode(scheme.schemeDetail.drainage_node_ID); + } + }; + + return ( + <> + {showTimeline && + mapContainer && + ReactDOM.createPortal( + , + mapContainer, + )} + + {/* Query Controls */} + + + + setQueryAll(e.target.checked)} + size="small" + /> + } + label={查询全部} + className="m-0" + /> + + + value && dayjs.isDayjs(value) && setQueryDate(value) + } + format="YYYY-MM-DD" + disabled={queryAll} + slotProps={{ + textField: { + size: "small", + sx: { width: 160 }, + }, + }} + /> + + + + + + + {/* Results List */} + + {schemes.length === 0 ? ( + + 暂无方案数据 + + ) : ( + + + 共 {schemes.length} 条记录 + + {schemes.map((scheme) => ( + + + + + + + {scheme.schemeName} + + + + + 用户: {scheme.user} · 时间: {formatTime(scheme.create_time)} + + + + + + scheme.schemeDetail?.drainage_node_ID && + handleLocateDrainageNode(scheme.schemeDetail.drainage_node_ID) + } + color="primary" + className="p-1" + > + + + + + + setExpandedId( + expandedId === scheme.id ? null : scheme.id, + ) + } + color="primary" + className="p-1" + > + + + + + + + + + + + + {/* 排水节点 */} + + + 排水节点: + + + {scheme.schemeDetail?.drainage_node_ID ? ( + { + e.preventDefault(); + handleLocateDrainageNode(scheme.schemeDetail!.drainage_node_ID); + }} + > + {scheme.schemeDetail.drainage_node_ID} + + ) : ( + + N/A + + )} + + + + {/* 冲洗流量 */} + + + 冲洗流量: + + + {scheme.schemeDetail?.flushing_flow ?? "-"} m³/h + + + + {/* 持续时长 */} + + + 持续时长: + + + {scheme.schemeDetail?.duration ?? "-"} 秒 + + + + + + + + {/* 用户 */} + + + 用户: + + + {scheme.user} + + + + {/* 创建时间 */} + + + 创建时间: + + + {formatTime(scheme.create_time)} + + + + {/* 开始时间 */} + + + 模拟开始: + + + {formatTime(scheme.startTime)} + + + + + + {/* 阀门列表 */} + + + 参与阀门及开度: + + + {scheme.schemeDetail?.valve_opening && Object.entries(scheme.schemeDetail.valve_opening).length > 0 ? ( + Object.entries(scheme.schemeDetail.valve_opening).map(([id, k]) => ( + + handleLocateValves([id])} + className="text-xs h-6 bg-gray-50 cursor-pointer hover:bg-orange-50 hover:border-orange-200" + /> + + )) + ) : ( + + )} + + + + + + + + + + + + + ))} + + )} + + + + ); +}; + +export default SchemeQuery; diff --git a/src/components/olmap/FlushingAnalysis/types.ts b/src/components/olmap/FlushingAnalysis/types.ts new file mode 100644 index 0000000..776bcad --- /dev/null +++ b/src/components/olmap/FlushingAnalysis/types.ts @@ -0,0 +1,27 @@ +export interface SchemeDetail { + valve_opening: Record; + drainage_node_ID: string; + flushing_flow: number; + duration: number; +} + +export interface SchemeRecord { + id: number; + schemeName: string; + type: string; + user: string; + create_time: string; + startTime: string; + // 详情信息 + schemeDetail?: SchemeDetail; +} + +export interface SchemaItem { + scheme_id: number; + scheme_name: string; + scheme_type: string; + username: string; + create_time: string; + scheme_start_time: string; + scheme_detail?: SchemeDetail; +} -- 2.54.0 From 62a97459d0e55fd9e99acd6a7096a1027eb024d5 Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 5 Feb 2026 17:39:49 +0800 Subject: [PATCH 007/281] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E5=86=B2=E6=B4=97=E7=82=B9=E5=87=BB=E6=8F=90=E7=A4=BA=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 438d684..01f08dd 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -288,7 +288,7 @@ const AnalysisParameters: React.FC = () => { {selectionMode === 'valve' && ( - 点击地图上的阀门进行添加 + 💡 点击地图上的阀门进行添加 )} @@ -336,7 +336,7 @@ const AnalysisParameters: React.FC = () => { {selectionMode === 'drainage' && ( - 点击地图上的节点作为排水点 + 💡 点击地图上的节点作为排水点 )} -- 2.54.0 From cbfce9164e879352649d6233af48beab6592e914 Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 5 Feb 2026 18:32:14 +0800 Subject: [PATCH 008/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=EF=BC=8C=E6=96=B0=E5=A2=9EschemeType=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hydraulic-simulation/pipe-burst-analysis/page.tsx | 2 +- src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx | 2 +- .../hydraulic-simulation/water-quality-simulation/page.tsx | 2 +- src/app/OlMap/Controls/Toolbar.tsx | 6 ++++-- src/app/_refine_context.tsx | 2 +- .../olmap/ContaminantSimulation/AnalysisParameters.tsx | 2 +- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx b/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx index 6cb111c..0942860 100644 --- a/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx +++ b/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx @@ -8,7 +8,7 @@ export default function Home() { return (
- +
diff --git a/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx b/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx index 0ff6b53..a0dcf6e 100644 --- a/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx +++ b/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx @@ -8,7 +8,7 @@ export default function Home() { return (
- +
diff --git a/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx index a3133fb..6929b05 100644 --- a/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx +++ b/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx @@ -8,7 +8,7 @@ export default function Home() { return (
- +
diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 73b2c2c..793ba65 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -25,11 +25,13 @@ import { config } from "@/config/config"; interface ToolbarProps { hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style'] queryType?: string; // 可选的查询类型参数 + schemeType?: string; // 可选的方案类型参数 HistoryPanel?: React.FC; // 可选的自定义历史数据面板 } const Toolbar: React.FC = ({ hiddenButtons, queryType, + schemeType, HistoryPanel, }) => { const map = useMap(); @@ -388,7 +390,7 @@ const Toolbar: React.FC = ({ if (queryType === "scheme") { response = await fetch( // `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}` - `${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`, + `${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`, ); } else { response = await fetch( @@ -408,7 +410,7 @@ const Toolbar: React.FC = ({ }; // 仅当 currentTime 有效时查询 if (currentTime !== -1 && queryType) queryComputedProperties(); - }, [highlightFeatures, currentTime, selectedDate]); + }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType]); // 从要素属性中提取属性面板需要的数据 const getFeatureProperties = useCallback(() => { diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index 170030f..bd0e26e 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -158,7 +158,7 @@ const App = (props: React.PropsWithChildren) => { name: "Hydraulic Simulation", meta: { icon: , - label: "水力模拟", + label: "事件模拟", }, }, { diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index d64035d..b23c8d2 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -181,7 +181,7 @@ const AnalysisParameters: React.FC = () => { source: sourceNode, concentration, duration, - pattern: pattern || "CONSTANT", + pattern: pattern || undefined, scheme_name: schemeName, }; -- 2.54.0 From 9d12b1960cc50bfd7ca2b7beec40bea33464a88d Mon Sep 17 00:00:00 2001 From: JIANG Date: Fri, 6 Feb 2026 11:32:50 +0800 Subject: [PATCH 009/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dscheme=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E5=B1=9E=E6=80=A7=E6=97=A0=E6=B3=95=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/Toolbar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 793ba65..8914b20 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -402,7 +402,13 @@ const Toolbar: React.FC = ({ throw new Error("API request failed"); } const data = await response.json(); - setComputedProperties(data.results[0] || {}); + if (!data.result || data.result.length === 0) { + setComputedProperties({}); + } else { + setComputedProperties(data.result[0] || {}); + console.log("查询到的计算属性:", data.result[0]); + console.log(computedProperties); + } } catch (error) { console.error("Error querying computed properties:", error); setComputedProperties({}); -- 2.54.0 From 6be4a0de14c16c9ce8874ea88a308b54a69237e2 Mon Sep 17 00:00:00 2001 From: JIANG Date: Fri, 6 Feb 2026 16:59:59 +0800 Subject: [PATCH 010/281] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=88=86=E7=AE=A1?= =?UTF-8?q?=E5=88=86=E6=9E=90=E4=BC=A0=E9=80=92=E7=9A=84=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx index 16bff99..15b611f 100644 --- a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx @@ -285,6 +285,9 @@ const AnalysisParameters: React.FC = () => { try { await axios.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, { params, + paramsSerializer: { + indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID + }, }); // 更新弹窗为成功状态 open?.({ -- 2.54.0 From 2c517851573040e7299c1be04c01bfcb0ee3f0c5 Mon Sep 17 00:00:00 2001 From: JIANG Date: Fri, 6 Feb 2026 17:47:55 +0800 Subject: [PATCH 011/281] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E6=B8=85=E6=B4=97=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 01f08dd..b36b6de 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -47,7 +47,7 @@ const AnalysisParameters: React.FC = () => { const [drainageFeature, setDrainageFeature] = useState(null); const [startTime, setStartTime] = useState(dayjs(new Date())); - const [flushFlow, setFlushFlow] = useState(0); + const [flushFlow, setFlushFlow] = useState(200); const [duration, setDuration] = useState(3600); const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none'); @@ -401,7 +401,7 @@ const AnalysisParameters: React.FC = () => { - 冲洗流量 + 冲洗流量 (CMH) Date: Mon, 9 Feb 2026 15:32:10 +0800 Subject: [PATCH 012/281] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E5=86=B2=E6=B4=97=E6=A8=A1=E5=9D=97=E6=B5=81=E9=87=8F=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E4=B8=BA0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index b36b6de..f7f3fe8 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -436,7 +436,7 @@ const AnalysisParameters: React.FC = () => { !schemeName.trim() || !drainageNode || !startTime || - !flushFlow || + // !flushFlow || !duration } className="bg-blue-600 hover:bg-blue-700" -- 2.54.0 From ae1f9b284f6ae62dfe925a2d1634d457fa52189f Mon Sep 17 00:00:00 2001 From: JIANG Date: Mon, 9 Feb 2026 15:32:35 +0800 Subject: [PATCH 013/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BE=BF=E4=BA=8E?= =?UTF-8?q?docker=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 15 +++++++++------ .env | 15 +++++++++++++++ Dockerfile | 18 ++++++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 .env diff --git a/.dockerignore b/.dockerignore index 95a8ae3..3cb48a6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,10 @@ -**/node_modules/ -**/dist +node_modules +.next +out +build .git -npm-debug.log -.coverage -.coverage.* -.env +.env*.local +README.md +docker-compose.yml +Dockerfile +.dockerignore \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..16c6687 --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_ISSUER= +NEXTAUTH_SECRET= +NEXTAUTH_URL= + +# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 +NEXT_PUBLIC_BACKEND_URL= +NEXT_PUBLIC_MAP_URL= +NEXT_PUBLIC_MAP_WORKSPACE= +NEXT_PUBLIC_MAP_EXTENT= +# NEXT_PUBLIC_MAP_AVAILABLE_LAYERS="junctions, pipes, reservoirs, scada" +NEXT_PUBLIC_NETWORK_NAME= +NEXT_PUBLIC_MAPBOX_TOKEN= +NEXT_PUBLIC_TIANDITU_TOKEN= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d792d2a..d299ac4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM refinedev/node:18 AS base +FROM refinedev/node:22 AS base FROM base AS deps @@ -15,6 +15,16 @@ RUN \ FROM base AS builder +# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数 +# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量 +ARG NEXT_PUBLIC_BACKEND_URL +ARG NEXT_PUBLIC_MAP_URL +ARG NEXT_PUBLIC_MAP_WORKSPACE +ARG NEXT_PUBLIC_MAP_EXTENT +ARG NEXT_PUBLIC_NETWORK_NAME +ARG NEXT_PUBLIC_MAPBOX_TOKEN +ARG NEXT_PUBLIC_TIANDITU_TOKEN + COPY --from=deps /app/refine/node_modules ./node_modules COPY . . @@ -23,7 +33,7 @@ RUN npm run build FROM base AS runner -ENV NODE_ENV production +ENV NODE_ENV=production COPY --from=builder /app/refine/public ./public @@ -37,7 +47,7 @@ USER refine EXPOSE 3000 -ENV PORT 3000 -ENV HOSTNAME "0.0.0.0" +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"] -- 2.54.0 From 1d15eeb17204358f35cb9d07bd16c02695d85980 Mon Sep 17 00:00:00 2001 From: JIANG Date: Tue, 10 Feb 2026 15:23:14 +0800 Subject: [PATCH 014/281] =?UTF-8?q?=E6=B0=B4=E8=B4=A8=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=AE=BE=E7=BD=AEpattern=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BACONSTANT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/ContaminantSimulation/AnalysisParameters.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index b23c8d2..9bb134a 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -175,6 +175,10 @@ const AnalysisParameters: React.FC = () => { ? startTime.format("YYYY-MM-DDTHH:mm:00Z") : ""; try { + if (!pattern) { + setPattern("CONSTANT"); + console.log("默认设置 pattern 为 CONSTANT"); + } const params = { network, start_time: start_time, -- 2.54.0 From 8ea70d04ad1c4f0bd783d5223f26921bd84bcdc9 Mon Sep 17 00:00:00 2001 From: JIANG Date: Tue, 10 Feb 2026 15:23:23 +0800 Subject: [PATCH 015/281] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 24 ++++++++++++------------ src/config/config.ts | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.env b/.env index 16c6687..b8260da 100644 --- a/.env +++ b/.env @@ -1,15 +1,15 @@ -KEYCLOAK_CLIENT_ID= -KEYCLOAK_CLIENT_SECRET= -KEYCLOAK_ISSUER= -NEXTAUTH_SECRET= -NEXTAUTH_URL= +KEYCLOAK_CLIENT_ID="tjwater" +KEYCLOAK_CLIENT_SECRET="83h0n413hau9bldzWdEaq6xRfASv24s5" +KEYCLOAK_ISSUER="https://keycloak.waternetwork.cn/realms/tjwater" +NEXTAUTH_SECRET="eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiS" +NEXTAUTH_URL="https://demo.waternetwork.cn/" # 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 -NEXT_PUBLIC_BACKEND_URL= -NEXT_PUBLIC_MAP_URL= -NEXT_PUBLIC_MAP_WORKSPACE= -NEXT_PUBLIC_MAP_EXTENT= +NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn" +NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" +NEXT_PUBLIC_MAP_WORKSPACE="szh" +NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" # NEXT_PUBLIC_MAP_AVAILABLE_LAYERS="junctions, pipes, reservoirs, scada" -NEXT_PUBLIC_NETWORK_NAME= -NEXT_PUBLIC_MAPBOX_TOKEN= -NEXT_PUBLIC_TIANDITU_TOKEN= \ No newline at end of file +NEXT_PUBLIC_NETWORK_NAME="szh" +NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg" +NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec" \ No newline at end of file diff --git a/src/config/config.ts b/src/config/config.ts index 692bd2c..be0afb4 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,6 +1,6 @@ export const config = { BACKEND_URL: - process.env.NEXT_PUBLIC_BACKEND_URL || "http://192.168.1.42:8000", + process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "TJWater", MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT -- 2.54.0 From 1e8af75b882f67b33b00eb0c751ae1b6d7592819 Mon Sep 17 00:00:00 2001 From: JIANG Date: Tue, 10 Feb 2026 16:13:04 +0800 Subject: [PATCH 016/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=BC=B9=E7=AA=97=EF=BC=88=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E9=80=89=E9=A1=B9=EF=BC=89=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/MapComponent.tsx | 9 +- src/app/_refine_context.tsx | 5 +- src/components/project/ProjectSelector.tsx | 146 +++++++++++++++++++++ src/config/config.ts | 11 +- src/contexts/ProjectContext.tsx | 64 +++++++++ src/utils/mapQueryService.ts | 10 +- 6 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 src/components/project/ProjectSelector.tsx create mode 100644 src/contexts/ProjectContext.tsx diff --git a/src/app/OlMap/MapComponent.tsx b/src/app/OlMap/MapComponent.tsx index c2e1068..86970ad 100644 --- a/src/app/OlMap/MapComponent.tsx +++ b/src/app/OlMap/MapComponent.tsx @@ -75,10 +75,6 @@ interface DataContextType { const MapContext = createContext(undefined); const DataContext = createContext(undefined); -const MAP_EXTENT = config.MAP_EXTENT as [number, number, number, number]; -const MAP_URL = config.MAP_URL; -const MAP_WORKSPACE = config.MAP_WORKSPACE; -const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key // 添加防抖函数 function debounce any>(func: F, waitFor: number) { let timeout: ReturnType | null = null; @@ -99,6 +95,11 @@ export const useData = () => { }; const MapComponent: React.FC = ({ children }) => { + const MAP_EXTENT = config.MAP_EXTENT as [number, number, number, number]; + const MAP_URL = config.MAP_URL; + const MAP_WORKSPACE = config.MAP_WORKSPACE; + const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key + const mapRef = useRef(null); const deckLayerRef = useRef(null); diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index bd0e26e..af98169 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -14,6 +14,7 @@ import routerProvider from "@refinedev/nextjs-router"; import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; +import { ProjectProvider } from "@/contexts/ProjectContext"; import { LiaNetworkWiredSolid } from "react-icons/lia"; import { TbDatabaseEdit } from "react-icons/tb"; @@ -32,7 +33,9 @@ export const RefineContext = ( ) => { return ( - + + + ); }; diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx new file mode 100644 index 0000000..7537cb2 --- /dev/null +++ b/src/components/project/ProjectSelector.tsx @@ -0,0 +1,146 @@ +import { Title } from "@components/title"; +import { + Dialog, + DialogContent, + DialogActions, + Button, + Select, + MenuItem, + FormControl, + InputLabel, + TextField, + Box, + Typography, + Fade, +} from "@mui/material"; +import { useState } from "react"; + +interface ProjectSelectorProps { + open: boolean; + onSelect: (workspace: string, networkName: string) => void; +} + +const PROJECTS = [ + { label: "TJWater (默认)", workspace: "TJWater", networkName: "tjwater" }, + { label: "苏州河", workspace: "szh", networkName: "szh" }, + { label: "测试项目", workspace: "test", networkName: "test" }, +]; + +export const ProjectSelector: React.FC = ({ + open, + onSelect, +}) => { + const [workspace, setWorkspace] = useState(PROJECTS[0].workspace); + const [networkName, setNetworkName] = useState(PROJECTS[0].networkName); + const [customMode, setCustomMode] = useState(false); + + const handleConfirm = () => { + onSelect(workspace, networkName); + }; + + return ( + + + + + </Box> + <Typography variant="subtitle1" color="text.secondary"> + 请选择项目环境 + </Typography> + </Box> + + <DialogContent sx={{ display: "flex", flexDirection: "column", gap: 3, pt: 1 }}> + {!customMode ? ( + <FormControl fullWidth variant="outlined"> + <InputLabel>项目</InputLabel> + <Select + value={workspace} + label="项目" + onChange={(e) => { + const val = e.target.value; + if (val === "custom") { + setCustomMode(true); + } else { + const p = PROJECTS.find((p) => p.workspace === val); + if (p) { + setWorkspace(p.workspace); + setNetworkName(p.networkName); + } + } + }} + > + {PROJECTS.map((p) => ( + <MenuItem key={p.workspace} value={p.workspace}> + <Box sx={{ display: "flex", flexDirection: "column" }}> + <Typography variant="body1">{p.label}</Typography> + <Typography variant="caption" color="text.secondary"> + 工作区: {p.workspace} | 管网: {p.networkName} + </Typography> + </Box> + </MenuItem> + ))} + <MenuItem value="custom"> + <Typography variant="body1">自定义配置...</Typography> + </MenuItem> + </Select> + </FormControl> + ) : ( + <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> + <TextField + label="Geoserver 工作区" + value={workspace} + onChange={(e) => setWorkspace(e.target.value)} + fullWidth + helperText="例如: TJWater" + /> + <TextField + label="管网名称" + value={networkName} + onChange={(e) => setNetworkName(e.target.value)} + fullWidth + helperText="例如: tjwater" + /> + <Button + onClick={() => setCustomMode(false)} + size="small" + sx={{ alignSelf: "flex-start" }} + > + 返回列表 + </Button> + </Box> + )} + </DialogContent> + <DialogActions sx={{ px: 3, pb: 2 }}> + <Button + onClick={handleConfirm} + variant="contained" + fullWidth + size="large" + sx={{ + textTransform: "none", + borderRadius: 2, + fontWeight: "bold" + }} + > + 进入系统 + </Button> + </DialogActions> + </Dialog> + ); +}; diff --git a/src/config/config.ts b/src/config/config.ts index be0afb4..71b03f3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -27,7 +27,16 @@ export const config = { ) : ["junctions", "pipes", "valves", "reservoirs", "pumps", "tanks", "scada"], }; -export const NETWORK_NAME = process.env.NEXT_PUBLIC_NETWORK_NAME || "tjwater"; +export let NETWORK_NAME = process.env.NEXT_PUBLIC_NETWORK_NAME || "tjwater"; + +export const setNetworkName = (name: string) => { + NETWORK_NAME = name; +}; + +export const setMapWorkspace = (workspace: string) => { + config.MAP_WORKSPACE = workspace; +}; + export const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || "pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"; diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx new file mode 100644 index 0000000..19f4e06 --- /dev/null +++ b/src/contexts/ProjectContext.tsx @@ -0,0 +1,64 @@ +"use client"; +import React, { createContext, useContext, useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import { config, NETWORK_NAME, setMapWorkspace, setNetworkName } from "@/config/config"; +import { ProjectSelector } from "@/components/project/ProjectSelector"; + +interface ProjectContextType { + workspace: string; + networkName: string; +} + +const ProjectContext = createContext<ProjectContextType | undefined>(undefined); + +export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { status } = useSession(); + const [isConfigured, setIsConfigured] = useState(false); + const [currentProject, setCurrentProject] = useState({ + workspace: config.MAP_WORKSPACE, + networkName: NETWORK_NAME || "tjwater", + }); + + useEffect(() => { + // Check localStorage + const savedWorkspace = localStorage.getItem("NEXT_PUBLIC_MAP_WORKSPACE"); + const savedNetwork = localStorage.getItem("NEXT_PUBLIC_NETWORK_NAME"); + + // If we have saved config, use it. + if (savedWorkspace && savedNetwork) { + applyConfig(savedWorkspace, savedNetwork); + } + }, []); + + const applyConfig = (ws: string, net: string) => { + setMapWorkspace(ws); + setNetworkName(net); + setCurrentProject({ workspace: ws, networkName: net }); + + // Save to localStorage + localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", ws); + localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", net); + + setIsConfigured(true); + }; + + // Only show selector if authenticated and not configured + if (status === "authenticated" && !isConfigured) { + return ( + <ProjectSelector + open={true} + onSelect={(ws, net) => applyConfig(ws, net)} + /> + ); + } + + return ( + <ProjectContext.Provider value={currentProject}> + {children} + </ProjectContext.Provider> + ); +}; + +export const useProject = () => useContext(ProjectContext); diff --git a/src/utils/mapQueryService.ts b/src/utils/mapQueryService.ts index 33ffff7..71db93b 100644 --- a/src/utils/mapQueryService.ts +++ b/src/utils/mapQueryService.ts @@ -40,15 +40,15 @@ interface MapClickEvent { // ========== 常量配置 ========== /** - * GeoServer 服务配置 + * GeoServer 服务配置获取函数 */ -const GEOSERVER_CONFIG = { +const getGeoserverConfig = () => ({ url: config.MAP_URL, workspace: config.MAP_WORKSPACE, layers: ["geo_pipes_mat", "geo_junctions_mat", "geo_valves"], wfsVersion: "1.0.0", outputFormat: "application/json", -} as const; +}); /** * 地图交互配置 @@ -176,7 +176,7 @@ const convertRenderFeatureToFeature = ( * @returns WFS 查询 URL */ const buildWfsUrl = (layer: string, orFilter: string): string => { - const { url, workspace, wfsVersion, outputFormat } = GEOSERVER_CONFIG; + const { url, workspace, wfsVersion, outputFormat } = getGeoserverConfig(); const params = new URLSearchParams({ service: "WFS", version: wfsVersion, @@ -233,7 +233,7 @@ const queryFeaturesByIds = async ( try { if (!layer) { // 查询所有配置的图层 - const promises = GEOSERVER_CONFIG.layers.map((layerName) => + const promises = getGeoserverConfig().layers.map((layerName) => fetchFeaturesFromLayer(layerName, orFilter) ); -- 2.54.0 From 25bde02b43bd779588eca1c585c50dcb2a70a667 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Tue, 10 Feb 2026 17:11:04 +0800 Subject: [PATCH 017/281] =?UTF-8?q?=E4=B8=BA=E7=99=BB=E5=BD=95=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E9=9D=A2=E6=96=B0=E5=A2=9E=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/header/index.tsx | 171 ++++++++++++++++++--- src/components/project/ProjectSelector.tsx | 22 ++- 2 files changed, 170 insertions(+), 23 deletions(-) diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index e54f9ca..5a17771 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -3,15 +3,25 @@ import { ColorModeContext } from "@contexts/color-mode"; import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined"; import LightModeOutlined from "@mui/icons-material/LightModeOutlined"; +import Logout from "@mui/icons-material/Logout"; +import SwapHoriz from "@mui/icons-material/SwapHoriz"; import AppBar from "@mui/material/AppBar"; import Avatar from "@mui/material/Avatar"; +import ButtonBase from "@mui/material/ButtonBase"; +import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; import Toolbar from "@mui/material/Toolbar"; import Typography from "@mui/material/Typography"; -import { useGetIdentity } from "@refinedev/core"; +import { useGetIdentity, useLogout } from "@refinedev/core"; import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui"; -import React, { useContext } from "react"; +import React, { useContext, useState } from "react"; +import { ProjectSelector } from "@components/project/ProjectSelector"; +import { setMapWorkspace, setNetworkName } from "@config/config"; type IUser = { id: number; @@ -23,9 +33,35 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ sticky = true, }) => { const { mode, setMode } = useContext(ColorModeContext); + const { mutate: logout } = useLogout(); + const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); + const [showProjectSelector, setShowProjectSelector] = useState(false); + const open = Boolean(anchorEl); const { data: user } = useGetIdentity<IUser>(); + const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleSwitchProjectClick = () => { + handleMenuClose(); + setShowProjectSelector(true); + }; + + const handleProjectSelect = (workspace: string, networkName: string) => { + setMapWorkspace(workspace); + setNetworkName(networkName); + localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", workspace); + localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName); + setShowProjectSelector(false); + window.location.reload(); + }; + return ( <AppBar position={sticky ? "sticky" : "relative"}> <Toolbar> @@ -52,27 +88,118 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ </IconButton> {(user?.avatar || user?.name) && ( - <Stack - direction="row" - gap="16px" - alignItems="center" - justifyContent="center" - > - {user?.name && ( - <Typography - sx={{ - display: { - xs: "none", - sm: "inline-block", - }, - }} - variant="subtitle2" + <> + <ButtonBase + onClick={handleMenuOpen} + sx={{ + borderRadius: "30px", + padding: "6px 12px", + marginLeft: "8px", + transition: "all 0.3s ease", + border: "1px solid transparent", + "&:hover": { + backgroundColor: + mode === "dark" + ? "rgba(255, 255, 255, 0.05)" + : "rgba(0, 0, 0, 0.04)", + transform: "translateY(-1px)", + border: `1px solid ${mode === "dark" + ? "rgba(255, 255, 255, 0.2)" + : "rgba(0, 0, 0, 0.1)" + }`, + boxShadow: + mode === "dark" + ? "0 4px 12px rgba(0,0,0,0.3)" + : "0 4px 12px rgba(0,0,0,0.08)", + }, + "&:active": { + transform: "translateY(0px)", + boxShadow: "none", + }, + }} + > + <Stack + direction="row" + gap="12px" + alignItems="center" + justifyContent="center" > - {user?.name} - </Typography> - )} - <Avatar src={user?.avatar} alt={user?.name} /> - </Stack> + {user?.name && ( + <Typography + sx={{ + display: { + xs: "none", + sm: "inline-block", + }, + fontWeight: 500, + }} + variant="subtitle2" + > + {user?.name} + </Typography> + )} + <Avatar + src={user?.avatar} + alt={user?.name} + sx={{ + width: 32, + height: 32, + border: `2px solid ${mode === "dark" + ? "rgba(255,255,255,0.2)" + : "rgba(0,0,0,0.1)" + }`, + transition: "transform 0.3s ease", + ".MuiButtonBase-root:hover &": { + transform: "rotate(5deg) scale(1.05)", + borderColor: "primary.main", + }, + }} + /> + </Stack> + </ButtonBase> + <Menu + anchorEl={anchorEl} + open={open} + onClose={handleMenuClose} + transformOrigin={{ horizontal: "right", vertical: "top" }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + PaperProps={{ + sx: { + borderRadius: 2, + minWidth: 180, + marginTop: "8px", + background: + mode === "dark" + ? "rgba(30, 30, 30, 0.95)" + : "rgba(255, 255, 255, 0.95)", + backdropFilter: "blur(10px)", + boxShadow: + mode === "dark" + ? "0px 4px 20px rgba(0, 0, 0, 0.5)" + : "0px 4px 20px rgba(0, 0, 0, 0.1)", + }, + }} + > + <MenuItem onClick={handleSwitchProjectClick}> + <ListItemIcon> + <SwapHoriz fontSize="small" /> + </ListItemIcon> + <ListItemText>切换项目</ListItemText> + </MenuItem> + <Divider /> + <MenuItem onClick={() => logout()}> + <ListItemIcon> + <Logout fontSize="small" /> + </ListItemIcon> + <ListItemText>登出</ListItemText> + </MenuItem> + </Menu> + <ProjectSelector + open={showProjectSelector} + onSelect={handleProjectSelect} + onClose={() => setShowProjectSelector(false)} + /> + </> )} </Stack> </Stack> diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index 7537cb2..03c951f 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -12,12 +12,15 @@ import { Box, Typography, Fade, + IconButton, } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; import { useState } from "react"; interface ProjectSelectorProps { open: boolean; onSelect: (workspace: string, networkName: string) => void; + onClose?: () => void; } const PROJECTS = [ @@ -29,6 +32,7 @@ const PROJECTS = [ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ open, onSelect, + onClose, }) => { const [workspace, setWorkspace] = useState(PROJECTS[0].workspace); const [networkName, setNetworkName] = useState(PROJECTS[0].networkName); @@ -41,7 +45,8 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ return ( <Dialog open={open} - disableEscapeKeyDown + disableEscapeKeyDown={!onClose} + onClose={onClose ? onClose : undefined} slotProps={{ paper: { sx: { @@ -50,12 +55,27 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ minWidth: 400, background: "rgba(255, 255, 255, 0.95)", backdropFilter: "blur(10px)", + position: "relative", } } }} slots={{ transition: Fade }} transitionDuration={500} > + {onClose && ( + <IconButton + aria-label="close" + onClick={onClose} + sx={{ + position: "absolute", + right: 8, + top: 8, + color: (theme) => theme.palette.grey[500], + }} + > + <CloseIcon /> + </IconButton> + )} <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", mb: 2 }}> <Box sx={{ transform: "scale(1.5)", mb: 2, mt: 1 }}> <Title /> -- 2.54.0 From 03e5f1456cc9b5046f722d9065dff4aecffdb472 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Feb 2026 11:52:06 +0800 Subject: [PATCH 018/281] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=AF=94=E4=BE=8B?= =?UTF-8?q?=E5=B0=BA=E6=8E=A7=E4=BB=B6=EF=BC=8C=E8=B0=83=E6=95=B4=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/BaseLayers.tsx | 2 +- src/app/OlMap/Controls/ScaleLine.tsx | 53 ++++++++++++++++++++++++--- src/app/OlMap/Controls/Zoom.tsx | 2 +- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/app/OlMap/Controls/BaseLayers.tsx b/src/app/OlMap/Controls/BaseLayers.tsx index 7f17bb1..03f2a76 100644 --- a/src/app/OlMap/Controls/BaseLayers.tsx +++ b/src/app/OlMap/Controls/BaseLayers.tsx @@ -179,7 +179,7 @@ const BaseLayers: React.FC = () => { }; return ( - <div className="absolute right-17 bottom-8 z-1300"> + <div className="absolute right-17 bottom-11 z-1300"> <div className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black" onMouseEnter={handleEnter} diff --git a/src/app/OlMap/Controls/ScaleLine.tsx b/src/app/OlMap/Controls/ScaleLine.tsx index 24070cd..81c41a6 100644 --- a/src/app/OlMap/Controls/ScaleLine.tsx +++ b/src/app/OlMap/Controls/ScaleLine.tsx @@ -1,10 +1,12 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { useMap } from "../MapComponent"; +import { ScaleLine } from "ol/control"; const Scale: React.FC = () => { const map = useMap(); const [zoomLevel, setZoomLevel] = useState(0); const [coordinates, setCoordinates] = useState<[number, number]>([0, 0]); + const scaleLineRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!map) return; @@ -28,19 +30,58 @@ const Scale: React.FC = () => { // Initialize values updateZoomLevel(); + // ScaleLine control + const scaleControl = new ScaleLine({ + target: scaleLineRef.current || undefined, + units: "metric", + bar: false, + steps: 4, + text: true, + minWidth: 64, + }); + map.addControl(scaleControl); + return () => { map.un("moveend", updateZoomLevel); map.un("pointermove", updateCoordinates); + map.removeControl(scaleControl); }; }, [map]); return ( - <div className="absolute bottom-0 right-0 flex col-auto px-2 bg-white bg-opacity-70 text-black rounded-tl shadow-md text-sm z-1300"> - <div className="px-1">缩放: {zoomLevel.toFixed(1)}</div> - <div className="px-1"> - 坐标: {coordinates[0]}, {coordinates[1]} + <> + <style> + {` + .custom-scale-line .ol-scale-line { + position: static; + background: transparent; + padding: 0; + } + .custom-scale-line .ol-scale-line-inner { + border: 1px solid #475569; + border-top: none; + color: #334155; + font-size: 0.75rem; + font-weight: 600; + transition: all 0.3s; + } + `} + </style> + <div className="absolute bottom-0 right-0 flex items-center gap-2 px-3 py-1.5 bg-white/90 hover:bg-white rounded-tl-xl shadow-lg backdrop-blur-sm text-xs font-medium text-slate-700 z-1300 transition-all duration-300 pointer-events-auto"> + <div + ref={scaleLineRef} + className="custom-scale-line flex items-center justify-center min-w-[60px]" + /> + <div className="h-3 w-px bg-slate-300 mx-1" /> + <div className="min-w-[60px] text-center"> + 缩放: {zoomLevel.toFixed(1)} + </div> + <div className="h-3 w-px bg-slate-300 mx-1" /> + <div className="tabular-nums min-w-[140px] text-center"> + 坐标: {coordinates[0]}, {coordinates[1]} + </div> </div> - </div> + </> ); }; diff --git a/src/app/OlMap/Controls/Zoom.tsx b/src/app/OlMap/Controls/Zoom.tsx index 7059a7d..e857abb 100644 --- a/src/app/OlMap/Controls/Zoom.tsx +++ b/src/app/OlMap/Controls/Zoom.tsx @@ -30,7 +30,7 @@ const Zoom: React.FC = () => { }; return ( - <div className="absolute right-4 bottom-8 z-1300"> + <div className="absolute right-4 bottom-11 z-1300"> <div className="w-8 h-26 flex flex-col gap-2 items-center"> <div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black"> <button -- 2.54.0 From 8b6198a2ac5c1859d4c486f9e2a5d8fd17d77d89 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Feb 2026 12:07:29 +0800 Subject: [PATCH 019/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E5=8F=82=E6=95=B0=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/layout.tsx | 31 ++++++------ src/app/OlMap/MapComponent.tsx | 8 +++- .../ZonePropsPanel.tsx | 8 +++- src/components/olmap/SCADADeviceList.tsx | 12 +++-- src/components/project/ProjectSelector.tsx | 48 +++++++++++++++---- src/config/config.ts | 11 +++-- src/contexts/ProjectContext.tsx | 32 ++++++++++--- 7 files changed, 106 insertions(+), 44 deletions(-) diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 0cf2e14..08719db 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; import React, { Suspense } from "react"; -import { RefineContext } from "../_refine_context"; import authOptions from "@app/api/auth/[...nextauth]/options"; import { Header } from "@components/header"; @@ -33,22 +32,20 @@ export default async function MainLayout({ } return ( - <RefineContext defaultMode={defaultMode}> - <ThemedLayout - Header={Header} - Title={Title} - childrenBoxProps={{ - sx: { height: "100vh", p: 0 }, - }} - containerBoxProps={{ - sx: { height: "100%" }, - }} - > - <Suspense fallback={<MapSkeleton />}> - {children} - </Suspense> - </ThemedLayout> - </RefineContext> + <ThemedLayout + Header={Header} + Title={Title} + childrenBoxProps={{ + sx: { height: "100vh", p: 0 }, + }} + containerBoxProps={{ + sx: { height: "100%" }, + }} + > + <Suspense fallback={<MapSkeleton />}> + {children} + </Suspense> + </ThemedLayout> ); } diff --git a/src/app/OlMap/MapComponent.tsx b/src/app/OlMap/MapComponent.tsx index 86970ad..7aebaf7 100644 --- a/src/app/OlMap/MapComponent.tsx +++ b/src/app/OlMap/MapComponent.tsx @@ -1,5 +1,6 @@ "use client"; import { config } from "@/config/config"; +import { useProject } from "@/contexts/ProjectContext"; import React, { createContext, useContext, @@ -97,7 +98,10 @@ export const useData = () => { const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const MAP_EXTENT = config.MAP_EXTENT as [number, number, number, number]; const MAP_URL = config.MAP_URL; - const MAP_WORKSPACE = config.MAP_WORKSPACE; + + // Subscribe to project context for workspace changes + const project = useProject(); + const MAP_WORKSPACE = project?.workspace || config.MAP_WORKSPACE; const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key const mapRef = useRef<HTMLDivElement | null>(null); @@ -763,7 +767,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { map.dispose(); deck.finalize(); }; - }, []); + }, [MAP_WORKSPACE, MAP_EXTENT]); // 当数据变化时,更新 deck.gl 图层 useEffect(() => { diff --git a/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx b/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx index 935dd88..6052313 100644 --- a/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx +++ b/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx @@ -7,6 +7,7 @@ import { Stroke } from "ol/style"; import GeoJson from "ol/format/GeoJSON"; import config from "@config/config"; import { useMap } from "@app/OlMap/MapComponent"; +import { useProject } from "@/contexts/ProjectContext"; interface PropertyItem { key: string; @@ -26,6 +27,8 @@ const ZonePropsPanel: React.FC<ZonePropsPanelProps> = ({ onClose, }) => { const map = useMap(); + const project = useProject(); + const workspace = project?.workspace; const [props, setProps] = React.useState< PropertyItem[] | Record<string, any> @@ -103,9 +106,10 @@ const ZonePropsPanel: React.FC<ZonePropsPanelProps> = ({ if (!map) { return; } + const workspaceValue = workspace || config.MAP_WORKSPACE; const networkZoneLayer = new VectorLayer({ source: new VectorSource({ - url: `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}:network_zone&outputFormat=application/json`, + url: `${config.MAP_URL}/${workspaceValue}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspaceValue}:network_zone&outputFormat=application/json`, format: new GeoJson(), }), style: new Style({ @@ -155,7 +159,7 @@ const ZonePropsPanel: React.FC<ZonePropsPanelProps> = ({ map.removeLayer(highlightLayer); map.un("click", clickListener); }; - }, [map, handleMapClickSelectFeatures]); + }, [map, handleMapClickSelectFeatures, workspace]); // 获取中文标签 const getChineseLabel = (key: string): string => { const labelMap: Record<string, string> = { diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADADeviceList.tsx index 0134a3e..5cf9dfa 100644 --- a/src/components/olmap/SCADADeviceList.tsx +++ b/src/components/olmap/SCADADeviceList.tsx @@ -50,9 +50,10 @@ import { FixedSizeList } from "react-window"; import { useNotification } from "@refinedev/core"; import axios from "axios"; import { useGetIdentity } from "@refinedev/core"; -import config, { NETWORK_NAME } from "@/config/config"; +import config from "@/config/config"; import { useMap } from "@app/OlMap/MapComponent"; +import { useProject } from "@/contexts/ProjectContext"; import { GeoJSON } from "ol/format"; import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import VectorLayer from "ol/layer/Vector"; @@ -180,12 +181,17 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ } }, [pendingSelection, onSelectionChange]); + // Get workspace from context + const project = useProject(); + const workspace = project?.workspace; + // 初始化 SCADA 设备列表 useEffect(() => { const fetchScadaDevices = async () => { setLoading(true); try { - const url = `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}:geo_scada&outputFormat=application/json`; + const activeWorkspace = workspace || config.MAP_WORKSPACE; + const url = `${config.MAP_URL}/${activeWorkspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${activeWorkspace}:geo_scada&outputFormat=application/json`; const response = await fetch(url); if (!response.ok) throw new Error("Failed to fetch SCADA devices"); const json = await response.json(); @@ -211,7 +217,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ } }; fetchScadaDevices(); - }, []); + }, [workspace]); const effectiveDevices = devices.length > 0 ? devices : internalDevices; diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index 03c951f..9e09cf7 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -19,14 +19,29 @@ import { useState } from "react"; interface ProjectSelectorProps { open: boolean; - onSelect: (workspace: string, networkName: string) => void; + onSelect: (workspace: string, networkName: string, extent?: number[]) => void; onClose?: () => void; } const PROJECTS = [ - { label: "TJWater (默认)", workspace: "TJWater", networkName: "tjwater" }, - { label: "苏州河", workspace: "szh", networkName: "szh" }, - { label: "测试项目", workspace: "test", networkName: "test" }, + { + label: "默认", + workspace: "tjwater", + networkName: "tjwater", + extent: [13508802, 3608164, 13555651, 3633686], + }, + { + label: "苏州河", + workspace: "szh", + networkName: "szh", + extent: [13490131, 3630016, 13525879, 3666969], + }, + { + label: "测试项目", + workspace: "test", + networkName: "test", + extent: [13508849, 3608036, 13555781, 3633813], + }, ]; export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ @@ -36,10 +51,13 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ }) => { const [workspace, setWorkspace] = useState(PROJECTS[0].workspace); const [networkName, setNetworkName] = useState(PROJECTS[0].networkName); + const [extent, setExtent] = useState<number[] | undefined>( + PROJECTS[0].extent, + ); const [customMode, setCustomMode] = useState(false); const handleConfirm = () => { - onSelect(workspace, networkName); + onSelect(workspace, networkName, extent); }; return ( @@ -56,8 +74,8 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ background: "rgba(255, 255, 255, 0.95)", backdropFilter: "blur(10px)", position: "relative", - } - } + }, + }, }} slots={{ transition: Fade }} transitionDuration={500} @@ -76,7 +94,14 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ <CloseIcon /> </IconButton> )} - <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", mb: 2 }}> + <Box + sx={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + mb: 2, + }} + > <Box sx={{ transform: "scale(1.5)", mb: 2, mt: 1 }}> <Title /> </Box> @@ -85,7 +110,9 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ </Typography> </Box> - <DialogContent sx={{ display: "flex", flexDirection: "column", gap: 3, pt: 1 }}> + <DialogContent + sx={{ display: "flex", flexDirection: "column", gap: 3, pt: 1 }} + > {!customMode ? ( <FormControl fullWidth variant="outlined"> <InputLabel>项目</InputLabel> @@ -101,6 +128,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ if (p) { setWorkspace(p.workspace); setNetworkName(p.networkName); + setExtent(p.extent); } } }} @@ -155,7 +183,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ sx={{ textTransform: "none", borderRadius: 2, - fontWeight: "bold" + fontWeight: "bold", }} > 进入系统 diff --git a/src/config/config.ts b/src/config/config.ts index 71b03f3..3aae064 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,11 +1,10 @@ export const config = { - BACKEND_URL: - process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", + BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", - MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "TJWater", + MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater", MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT ? process.env.NEXT_PUBLIC_MAP_EXTENT.split(",").map(Number) - : [13508849, 3608035.75, 13555781, 3633812.75], + : [13508849, 3608036, 13555781, 3633813], MAP_DEFAULT_STYLE: { "stroke-width": 3, "stroke-color": "rgba(51, 153, 204, 0.9)", @@ -37,6 +36,10 @@ export const setMapWorkspace = (workspace: string) => { config.MAP_WORKSPACE = workspace; }; +export const setMapExtent = (extent: number[]) => { + config.MAP_EXTENT = extent; +}; + export const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || "pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"; diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 19f4e06..add492f 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -1,7 +1,7 @@ "use client"; import React, { createContext, useContext, useEffect, useState } from "react"; import { useSession } from "next-auth/react"; -import { config, NETWORK_NAME, setMapWorkspace, setNetworkName } from "@/config/config"; +import { config, NETWORK_NAME, setMapWorkspace, setNetworkName, setMapExtent } from "@/config/config"; import { ProjectSelector } from "@/components/project/ProjectSelector"; interface ProjectContextType { @@ -25,23 +25,43 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ // Check localStorage const savedWorkspace = localStorage.getItem("NEXT_PUBLIC_MAP_WORKSPACE"); const savedNetwork = localStorage.getItem("NEXT_PUBLIC_NETWORK_NAME"); + const savedExtent = localStorage.getItem("NEXT_PUBLIC_MAP_EXTENT"); // If we have saved config, use it. if (savedWorkspace && savedNetwork) { - applyConfig(savedWorkspace, savedNetwork); + applyConfig( + savedWorkspace, + savedNetwork, + savedExtent ? savedExtent.split(",").map(Number) : undefined, + ); } }, []); - const applyConfig = (ws: string, net: string) => { + const applyConfig = async (ws: string, net: string, extent?: number[]) => { setMapWorkspace(ws); setNetworkName(net); + if (extent) { + setMapExtent(extent); + localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); + // Reset extent cache + localStorage.removeItem(`${ws}_map_view`); + } + setCurrentProject({ workspace: ws, networkName: net }); - + // Save to localStorage localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", ws); localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", net); - + setIsConfigured(true); + + try { + await fetch(`${config.BACKEND_URL}/openproject/?network=${net}`, { + method: "POST", + }); + } catch (error) { + console.error("Failed to open project:", error); + } }; // Only show selector if authenticated and not configured @@ -49,7 +69,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ return ( <ProjectSelector open={true} - onSelect={(ws, net) => applyConfig(ws, net)} + onSelect={(ws, net, extent) => applyConfig(ws, net, extent)} /> ); } -- 2.54.0 From 2911b87fac7086b609d8466f3aaa144cc5476b34 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Feb 2026 13:53:56 +0800 Subject: [PATCH 020/281] =?UTF-8?q?=E6=8F=90=E5=8D=87extent=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E7=8A=B6=E6=80=81=EF=BC=9B=E4=BF=AE=E6=94=B9=E9=83=A8?= =?UTF-8?q?=E5=88=86=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/MapComponent.tsx | 13 ++++++++----- src/components/project/ProjectSelector.tsx | 2 +- src/contexts/ProjectContext.tsx | 4 +++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/OlMap/MapComponent.tsx b/src/app/OlMap/MapComponent.tsx index 7aebaf7..99c053d 100644 --- a/src/app/OlMap/MapComponent.tsx +++ b/src/app/OlMap/MapComponent.tsx @@ -96,12 +96,15 @@ export const useData = () => { }; const MapComponent: React.FC<MapComponentProps> = ({ children }) => { - const MAP_EXTENT = config.MAP_EXTENT as [number, number, number, number]; - const MAP_URL = config.MAP_URL; - - // Subscribe to project context for workspace changes const project = useProject(); const MAP_WORKSPACE = project?.workspace || config.MAP_WORKSPACE; + const MAP_EXTENT = (project?.extent || config.MAP_EXTENT) as [ + number, + number, + number, + number, + ]; + const MAP_URL = config.MAP_URL; const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key const mapRef = useRef<HTMLDivElement | null>(null); @@ -464,7 +467,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const scadaLayer = new VectorLayer({ source: scadaSource, style: scadaStyle, - // extent: extent, // 设置图层范围 + extent: MAP_EXTENT, // 设置图层范围 maxZoom: 24, minZoom: 11, properties: { diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index 9e09cf7..49cc6a7 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -155,7 +155,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ value={workspace} onChange={(e) => setWorkspace(e.target.value)} fullWidth - helperText="例如: TJWater" + helperText="例如: tjwater" /> <TextField label="管网名称" diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index add492f..edc3340 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -7,6 +7,7 @@ import { ProjectSelector } from "@/components/project/ProjectSelector"; interface ProjectContextType { workspace: string; networkName: string; + extent: number[]; } const ProjectContext = createContext<ProjectContextType | undefined>(undefined); @@ -19,6 +20,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ const [currentProject, setCurrentProject] = useState({ workspace: config.MAP_WORKSPACE, networkName: NETWORK_NAME || "tjwater", + extent: config.MAP_EXTENT, }); useEffect(() => { @@ -47,7 +49,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ localStorage.removeItem(`${ws}_map_view`); } - setCurrentProject({ workspace: ws, networkName: net }); + setCurrentProject({ workspace: ws, networkName: net, extent: extent || config.MAP_EXTENT }); // Save to localStorage localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", ws); -- 2.54.0 From a2e6c1f416d6506222aaab34a0a9c1cc5e6834a4 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Feb 2026 14:17:16 +0800 Subject: [PATCH 021/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMAP=5FEXTENT=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9B=B4=E6=96=B0=E7=9A=84BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/header/index.tsx | 11 +++++++++-- src/components/project/ProjectSelector.tsx | 4 ++-- src/contexts/ProjectContext.tsx | 17 +++++++---------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 5a17771..47256da 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -21,7 +21,7 @@ import { useGetIdentity, useLogout } from "@refinedev/core"; import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui"; import React, { useContext, useState } from "react"; import { ProjectSelector } from "@components/project/ProjectSelector"; -import { setMapWorkspace, setNetworkName } from "@config/config"; +import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; type IUser = { id: number; @@ -53,11 +53,18 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ setShowProjectSelector(true); }; - const handleProjectSelect = (workspace: string, networkName: string) => { + const handleProjectSelect = ( + workspace: string, + networkName: string, + extent: number[], + ) => { setMapWorkspace(workspace); setNetworkName(networkName); + setMapExtent(extent); localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", workspace); localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName); + localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); + localStorage.removeItem(`${workspace}_map_view`); setShowProjectSelector(false); window.location.reload(); }; diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index 49cc6a7..5f8b6da 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -19,7 +19,7 @@ import { useState } from "react"; interface ProjectSelectorProps { open: boolean; - onSelect: (workspace: string, networkName: string, extent?: number[]) => void; + onSelect: (workspace: string, networkName: string, extent: number[]) => void; onClose?: () => void; } @@ -51,7 +51,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ }) => { const [workspace, setWorkspace] = useState(PROJECTS[0].workspace); const [networkName, setNetworkName] = useState(PROJECTS[0].networkName); - const [extent, setExtent] = useState<number[] | undefined>( + const [extent, setExtent] = useState<number[]>( PROJECTS[0].extent, ); const [customMode, setCustomMode] = useState(false); diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index edc3340..5c7e367 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -34,22 +34,19 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ applyConfig( savedWorkspace, savedNetwork, - savedExtent ? savedExtent.split(",").map(Number) : undefined, + savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT, ); } }, []); - const applyConfig = async (ws: string, net: string, extent?: number[]) => { + const applyConfig = async (ws: string, net: string, extent: number[]) => { setMapWorkspace(ws); setNetworkName(net); - if (extent) { - setMapExtent(extent); - localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); - // Reset extent cache - localStorage.removeItem(`${ws}_map_view`); - } - - setCurrentProject({ workspace: ws, networkName: net, extent: extent || config.MAP_EXTENT }); + setMapExtent(extent); + localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); + // Reset extent cache + localStorage.removeItem(`${ws}_map_view`); + setCurrentProject({ workspace: ws, networkName: net, extent: extent }); // Save to localStorage localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", ws); -- 2.54.0 From 9d06226cb4d6860c16f3ccd83206695f5e264002 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Feb 2026 16:29:18 +0800 Subject: [PATCH 022/281] Implemented a Zustand-based project_id store, expanded project selection/switching to persist project_id, and centralized backend requests via api/apiFetch (including data provider updates) to inject X-Project-ID. --- package-lock.json | 32 ++++++++++++++++- package.json | 3 +- src/app/OlMap/Controls/HistoryDataPanel.tsx | 17 +++++----- src/app/OlMap/Controls/Timeline.tsx | 11 +++--- src/app/OlMap/Controls/Toolbar.tsx | 5 +-- src/components/header/index.tsx | 6 ++++ .../BurstPipeAnalysis/AnalysisParameters.tsx | 4 +-- .../BurstPipeAnalysisPanel.tsx | 4 +-- .../olmap/BurstPipeAnalysis/SchemeQuery.tsx | 4 +-- .../BurstPipeAnalysis/ValveIsolation.tsx | 4 +-- .../AnalysisParameters.tsx | 4 +-- .../ContaminantSimulation/SchemeQuery.tsx | 4 +-- .../FlushingAnalysis/AnalysisParameters.tsx | 4 +-- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 4 +-- .../olmap/HealthRiskAnalysis/Timeline.tsx | 3 +- .../OptimizationParameters.tsx | 4 +-- .../SchemeQuery.tsx | 4 +-- src/components/olmap/SCADADataPanel.tsx | 17 +++++----- src/components/olmap/SCADADeviceList.tsx | 4 +-- src/components/project/ProjectSelector.tsx | 34 ++++++++++++++----- src/contexts/ProjectContext.tsx | 22 ++++++++++-- src/lib/api.ts | 18 ++++++++++ src/lib/apiFetch.ts | 10 ++++++ src/providers/data-provider/index.ts | 5 ++- src/store/projectStore.ts | 27 +++++++++++++++ 25 files changed, 192 insertions(+), 62 deletions(-) create mode 100644 src/lib/api.ts create mode 100644 src/lib/apiFetch.ts create mode 100644 src/store/projectStore.ts diff --git a/package-lock.json b/package-lock.json index 3eab56a..c8bfce4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "react-draggable": "^4.5.0", "react-icons": "^5.5.0", "react-window": "^1.8.10", - "tailwindcss": "^4.1.13" + "tailwindcss": "^4.1.13", + "zustand": "^5.0.11" }, "devDependencies": { "@svgr/webpack": "^8.1.0", @@ -22904,6 +22905,35 @@ "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==", "license": "MIT AND BSD-3-Clause" }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", diff --git a/package.json b/package.json index a3f6c50..a144984 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "react-draggable": "^4.5.0", "react-icons": "^5.5.0", "react-window": "^1.8.10", - "tailwindcss": "^4.1.13" + "tailwindcss": "^4.1.13", + "zustand": "^5.0.11" }, "overrides": { "fast-xml-parser": "5.3.4" diff --git a/src/app/OlMap/Controls/HistoryDataPanel.tsx b/src/app/OlMap/Controls/HistoryDataPanel.tsx index ae0b32e..bd4f8c5 100644 --- a/src/app/OlMap/Controls/HistoryDataPanel.tsx +++ b/src/app/OlMap/Controls/HistoryDataPanel.tsx @@ -34,6 +34,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import config from "@/config/config"; +import { apiFetch } from "@/lib/apiFetch"; dayjs.extend(utc); dayjs.extend(timezone); @@ -103,10 +104,10 @@ const fetchFromBackend = async ( if (type === "none") { // 查询清洗值和监测值 const [cleanedRes, rawRes] = await Promise.all([ - fetch(cleanedDataUrl) + apiFetch(cleanedDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(rawDataUrl) + apiFetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); @@ -126,13 +127,13 @@ const fetchFromBackend = async ( } else if (type === "scheme") { // 查询策略模拟值、清洗值和监测值 const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([ - fetch(cleanedDataUrl) + apiFetch(cleanedDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(rawDataUrl) + apiFetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(schemeSimulationDataUrl) + apiFetch(schemeSimulationDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); @@ -178,13 +179,13 @@ const fetchFromBackend = async ( } else { // realtime: 查询模拟值、清洗值和监测值 const [cleanedRes, rawRes, simulationRes] = await Promise.all([ - fetch(cleanedDataUrl) + apiFetch(cleanedDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(rawDataUrl) + apiFetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(simulationDataUrl) + apiFetch(simulationDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); diff --git a/src/app/OlMap/Controls/Timeline.tsx b/src/app/OlMap/Controls/Timeline.tsx index 0688edb..f7cc65f 100644 --- a/src/app/OlMap/Controls/Timeline.tsx +++ b/src/app/OlMap/Controls/Timeline.tsx @@ -28,6 +28,7 @@ import { TbRewindBackward15, TbRewindForward15 } from "react-icons/tb"; import { FiSkipBack, FiSkipForward } from "react-icons/fi"; import { useData } from "../MapComponent"; import { config, NETWORK_NAME } from "@/config/config"; +import { apiFetch } from "@/lib/apiFetch"; import { useMap } from "../MapComponent"; interface TimelineProps { @@ -117,11 +118,11 @@ const Timeline: React.FC<TimelineProps> = ({ nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!; } else { disableDateSelection && schemeName - ? (nodePromise = fetch( + ? (nodePromise = apiFetch( // `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}` `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`, )) - : (nodePromise = fetch( + : (nodePromise = apiFetch( // `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}` `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`, )); @@ -138,11 +139,11 @@ const Timeline: React.FC<TimelineProps> = ({ linkRecords = linkCacheRef.current.get(linkCacheKey)!; } else { disableDateSelection && schemeName - ? (linkPromise = fetch( + ? (linkPromise = apiFetch( // `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}` `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`, )) - : (linkPromise = fetch( + : (linkPromise = apiFetch( // `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}` `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`, )); @@ -513,7 +514,7 @@ const Timeline: React.FC<TimelineProps> = ({ duration: calculatedInterval, }; - const response = await fetch( + const response = await apiFetch( `${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`, { method: "POST", diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 8914b20..7a763e2 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -20,6 +20,7 @@ import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/ import { useNotification } from "@refinedev/core"; import { config } from "@/config/config"; +import { apiFetch } from "@/lib/apiFetch"; // 添加接口定义隐藏按钮的props interface ToolbarProps { @@ -388,12 +389,12 @@ const Toolbar: React.FC<ToolbarProps> = ({ const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z" let response; if (queryType === "scheme") { - response = await fetch( + response = await apiFetch( // `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}` `${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`, ); } else { - response = await fetch( + response = await apiFetch( // `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}` `${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`, ); diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 47256da..ee70181 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -22,6 +22,7 @@ import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui"; import React, { useContext, useState } from "react"; import { ProjectSelector } from "@components/project/ProjectSelector"; import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; +import { useProjectStore } from "@/store/projectStore"; type IUser = { id: number; @@ -37,6 +38,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [showProjectSelector, setShowProjectSelector] = useState(false); const open = Boolean(anchorEl); + const setCurrentProjectId = useProjectStore( + (state) => state.setCurrentProjectId, + ); const { data: user } = useGetIdentity<IUser>(); @@ -54,6 +58,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ }; const handleProjectSelect = ( + projectId: string, workspace: string, networkName: string, extent: number[], @@ -65,6 +70,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName); localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); localStorage.removeItem(`${workspace}_map_view`); + setCurrentProjectId(projectId || networkName || workspace); setShowProjectSelector(false); window.location.reload(); }; diff --git a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx index 15b611f..7e242c0 100644 --- a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx @@ -23,7 +23,7 @@ import { Style, Stroke, Icon } from "ol/style"; import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import Feature, { FeatureLike } from "ol/Feature"; import { useNotification } from "@refinedev/core"; -import axios from "axios"; +import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; import { along, lineString, length, toMercator } from "@turf/turf"; import { Point } from "ol/geom"; @@ -283,7 +283,7 @@ const AnalysisParameters: React.FC = () => { }; try { - await axios.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, { + await api.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, { params, paramsSerializer: { indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID diff --git a/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx index 6d204cc..5f7302a 100644 --- a/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx +++ b/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx @@ -22,7 +22,7 @@ import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; import LocationResults from "./LocationResults"; import ValveIsolation from "./ValveIsolation"; -import axios from "axios"; +import { api } from "@/lib/api"; import { config } from "@config/config"; import { useNotification } from "@refinedev/core"; import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types"; @@ -85,7 +85,7 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ const handleLocateScheme = async (scheme: SchemeRecord) => { try { - const response = await axios.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`, ); setLocationResults(response.data); diff --git a/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx b/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx index 34dfcf3..b1c5e6b 100644 --- a/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx @@ -26,7 +26,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import "dayjs/locale/zh-cn"; // 引入中文包 import dayjs, { Dayjs } from "dayjs"; -import axios from "axios"; +import { api } from "@/lib/api"; import moment from "moment"; import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; @@ -109,7 +109,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { - const response = await axios.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`, ); let filteredResults = response.data; diff --git a/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx b/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx index 765a8dd..7553bbc 100644 --- a/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx +++ b/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx @@ -27,7 +27,7 @@ import { CheckBox as CheckBoxIcon, CheckBoxOutlineBlank as CheckBoxOutlineBlankIcon, } from "@mui/icons-material"; -import axios from "axios"; +import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@config/config"; import { ValveIsolationResult } from "./types"; import { useNotification } from "@refinedev/core"; @@ -270,7 +270,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ if (disabled.length > 0) { params.disabled_valves = disabled; } - const response = await axios.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/valve_isolation_analysis/`, { params, diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index 9bb134a..ee58be3 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -17,7 +17,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import "dayjs/locale/zh-cn"; import dayjs, { Dayjs } from "dayjs"; import { useNotification } from "@refinedev/core"; -import axios from "axios"; +import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; import { useMap } from "@app/OlMap/MapComponent"; import VectorLayer from "ol/layer/Vector"; @@ -189,7 +189,7 @@ const AnalysisParameters: React.FC = () => { scheme_name: schemeName, }; - await axios.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, { + await api.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, { params, }); diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index a7db304..21d29fd 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -25,7 +25,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import "dayjs/locale/zh-cn"; import dayjs, { Dayjs } from "dayjs"; -import axios from "axios"; +import { api } from "@/lib/api"; import moment from "moment"; import { useNotification } from "@refinedev/core"; import { config, NETWORK_NAME } from "@config/config"; @@ -180,7 +180,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ if (!queryAll && !queryDate) return; setLoading(true); try { - const response = await axios.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`, ); let filteredResults = response.data; diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index f7f3fe8..241e666 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -25,7 +25,7 @@ import { Style, Stroke, Fill, Circle as CircleStyle } from "ol/style"; import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import Feature, { FeatureLike } from "ol/Feature"; import { useNotification } from "@refinedev/core"; -import axios from "axios"; +import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; interface ValveItem { @@ -242,7 +242,7 @@ const AnalysisParameters: React.FC = () => { // but axios usually handles array as valves[]=1&valves[]=2 // FastAPI default expects repeated query params. - const response = await axios.get(`${config.BACKEND_URL}/flushing_analysis/`, { + const response = await api.get(`${config.BACKEND_URL}/flushing_analysis/`, { params, // Ensure arrays are sent as repeated keys: valves=1&valves=2 paramsSerializer: { diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index eb75457..ed927a6 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -27,7 +27,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import "dayjs/locale/zh-cn"; import dayjs, { Dayjs } from "dayjs"; -import axios from "axios"; +import { api } from "@/lib/api"; import moment from "moment"; import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; @@ -221,7 +221,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { - const response = await axios.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`, ); diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index 382a4c3..957a99b 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -29,6 +29,7 @@ import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb"; import { FiSkipBack, FiSkipForward } from "react-icons/fi"; import { useData } from "../../../app/OlMap/MapComponent"; import { config, NETWORK_NAME } from "@/config/config"; +import { apiFetch } from "@/lib/apiFetch"; import { useMap } from "../../../app/OlMap/MapComponent"; import { useHealthRisk } from "./HealthRiskContext"; import { @@ -422,7 +423,7 @@ const Timeline: React.FC<TimelineProps> = ({ undoableTimeout: 3, }); try { - const response = await fetch( + const response = await apiFetch( `${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`, ); diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index de01cf5..4c1cb67 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -12,7 +12,7 @@ import { import { PlayArrow as PlayArrowIcon } from "@mui/icons-material"; import { useNotification } from "@refinedev/core"; import { useGetIdentity } from "@refinedev/core"; -import axios from "axios"; +import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; type IUser = { @@ -93,7 +93,7 @@ const OptimizationParameters: React.FC = () => { try { // 发送优化请求 - const response = await axios.post( + const response = await api.post( `${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`, null, { diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index ad36ab6..25560d0 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -24,7 +24,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import "dayjs/locale/zh-cn"; // 引入中文包 import dayjs, { Dayjs } from "dayjs"; -import axios from "axios"; +import { api } from "@/lib/api"; import moment from "moment"; import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; @@ -148,7 +148,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { - const response = await axios.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`, ); diff --git a/src/components/olmap/SCADADataPanel.tsx b/src/components/olmap/SCADADataPanel.tsx index 833da86..a6c53dc 100644 --- a/src/components/olmap/SCADADataPanel.tsx +++ b/src/components/olmap/SCADADataPanel.tsx @@ -37,7 +37,8 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import config from "@/config/config"; import { useGetIdentity } from "@refinedev/core"; import { useNotification } from "@refinedev/core"; -import axios from "axios"; +import { api } from "@/lib/api"; +import { apiFetch } from "@/lib/apiFetch"; dayjs.extend(utc); dayjs.extend(timezone); @@ -96,10 +97,10 @@ const fetchFromBackend = async ( try { // 优先查询清洗数据和模拟数据 const [cleaningRes, simulationRes] = await Promise.all([ - fetch(cleaningDataUrl) + apiFetch(cleaningDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(simulationDataUrl) + apiFetch(simulationDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); @@ -118,7 +119,7 @@ const fetchFromBackend = async ( ); } else { // 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据 - const rawRes = await fetch(rawDataUrl) + const rawRes = await apiFetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null); const rawData = transformBackendData(rawRes, deviceIds); @@ -338,13 +339,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`; try { const [cleanRes, rawRes, simRes] = await Promise.all([ - fetch(cleaningDataUrl) + apiFetch(cleaningDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(rawDataUrl) + apiFetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), - fetch(simulationDataUrl) + apiFetch(simulationDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); @@ -474,7 +475,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ const endTime = dayjs(rangeTo).toISOString(); // 调用后端清洗接口 - const response = await axios.post( + const response = await api.post( `${ config.BACKEND_URL }/api/v1/composite/clean-scada?device_ids=${deviceIds.join( diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADADeviceList.tsx index 5cf9dfa..2c6f200 100644 --- a/src/components/olmap/SCADADeviceList.tsx +++ b/src/components/olmap/SCADADeviceList.tsx @@ -48,7 +48,7 @@ import { } from "@mui/icons-material"; import { FixedSizeList } from "react-window"; import { useNotification } from "@refinedev/core"; -import axios from "axios"; +import { api } from "@/lib/api"; import { useGetIdentity } from "@refinedev/core"; import config from "@/config/config"; @@ -622,7 +622,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ const endTime = dayjs(cleanEndTime).toISOString(); // 调用后端清洗接口 - const response = await axios.post( + const response = await api.post( `${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`, ); diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index 5f8b6da..b5fe3ec 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -19,24 +19,31 @@ import { useState } from "react"; interface ProjectSelectorProps { open: boolean; - onSelect: (workspace: string, networkName: string, extent: number[]) => void; + onSelect: ( + projectId: string, + workspace: string, + networkName: string, + extent: number[], + ) => void; onClose?: () => void; } const PROJECTS = [ { + id: "tjwater", label: "默认", workspace: "tjwater", networkName: "tjwater", extent: [13508802, 3608164, 13555651, 3633686], }, + // { + // label: "苏州河", + // workspace: "szh", + // networkName: "szh", + // extent: [13490131, 3630016, 13525879, 3666969], + // }, { - label: "苏州河", - workspace: "szh", - networkName: "szh", - extent: [13490131, 3630016, 13525879, 3666969], - }, - { + id: "test", label: "测试项目", workspace: "test", networkName: "test", @@ -49,6 +56,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ onSelect, onClose, }) => { + const [projectId, setProjectId] = useState(PROJECTS[0].id); const [workspace, setWorkspace] = useState(PROJECTS[0].workspace); const [networkName, setNetworkName] = useState(PROJECTS[0].networkName); const [extent, setExtent] = useState<number[]>( @@ -57,7 +65,8 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ const [customMode, setCustomMode] = useState(false); const handleConfirm = () => { - onSelect(workspace, networkName, extent); + const resolvedProjectId = projectId.trim() || workspace || networkName; + onSelect(resolvedProjectId, workspace, networkName, extent); }; return ( @@ -123,9 +132,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ const val = e.target.value; if (val === "custom") { setCustomMode(true); + setProjectId(workspace); } else { const p = PROJECTS.find((p) => p.workspace === val); if (p) { + setProjectId(p.id); setWorkspace(p.workspace); setNetworkName(p.networkName); setExtent(p.extent); @@ -150,6 +161,13 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ </FormControl> ) : ( <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> + <TextField + label="项目 ID" + value={projectId} + onChange={(e) => setProjectId(e.target.value)} + fullWidth + helperText="例如: tjwater" + /> <TextField label="Geoserver 工作区" value={workspace} diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 5c7e367..7eb214a 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -3,6 +3,8 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { useSession } from "next-auth/react"; import { config, NETWORK_NAME, setMapWorkspace, setNetworkName, setMapExtent } from "@/config/config"; import { ProjectSelector } from "@/components/project/ProjectSelector"; +import { apiFetch } from "@/lib/apiFetch"; +import { useProjectStore } from "@/store/projectStore"; interface ProjectContextType { workspace: string; @@ -17,6 +19,9 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ }) => { const { status } = useSession(); const [isConfigured, setIsConfigured] = useState(false); + const setCurrentProjectId = useProjectStore( + (state) => state.setCurrentProjectId, + ); const [currentProject, setCurrentProject] = useState({ workspace: config.MAP_WORKSPACE, networkName: NETWORK_NAME || "tjwater", @@ -28,10 +33,12 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ const savedWorkspace = localStorage.getItem("NEXT_PUBLIC_MAP_WORKSPACE"); const savedNetwork = localStorage.getItem("NEXT_PUBLIC_NETWORK_NAME"); const savedExtent = localStorage.getItem("NEXT_PUBLIC_MAP_EXTENT"); + const savedProjectId = localStorage.getItem("active_project"); // If we have saved config, use it. if (savedWorkspace && savedNetwork) { applyConfig( + savedProjectId || savedNetwork || savedWorkspace, savedWorkspace, savedNetwork, savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT, @@ -39,7 +46,13 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ } }, []); - const applyConfig = async (ws: string, net: string, extent: number[]) => { + const applyConfig = async ( + projectId: string, + ws: string, + net: string, + extent: number[], + ) => { + const resolvedProjectId = projectId || net || ws; setMapWorkspace(ws); setNetworkName(net); setMapExtent(extent); @@ -47,6 +60,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ // Reset extent cache localStorage.removeItem(`${ws}_map_view`); setCurrentProject({ workspace: ws, networkName: net, extent: extent }); + setCurrentProjectId(resolvedProjectId); // Save to localStorage localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", ws); @@ -55,7 +69,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ setIsConfigured(true); try { - await fetch(`${config.BACKEND_URL}/openproject/?network=${net}`, { + await apiFetch(`${config.BACKEND_URL}/openproject/?network=${net}`, { method: "POST", }); } catch (error) { @@ -68,7 +82,9 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ return ( <ProjectSelector open={true} - onSelect={(ws, net, extent) => applyConfig(ws, net, extent)} + onSelect={(projectId, ws, net, extent) => + applyConfig(projectId, ws, net, extent) + } /> ); } diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..cc7aea8 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,18 @@ +import axios from "axios"; +import { config } from "@config/config"; +import { useProjectStore } from "@/store/projectStore"; + +export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL; + +export const api = axios.create({ + baseURL: API_URL, +}); + +api.interceptors.request.use((request) => { + const projectId = useProjectStore.getState().currentProjectId; + if (projectId) { + request.headers = request.headers ?? {}; + request.headers["X-Project-ID"] = projectId; + } + return request; +}); diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts new file mode 100644 index 0000000..1c8c531 --- /dev/null +++ b/src/lib/apiFetch.ts @@ -0,0 +1,10 @@ +import { useProjectStore } from "@/store/projectStore"; + +export const apiFetch = (input: RequestInfo | URL, init: RequestInit = {}) => { + const projectId = useProjectStore.getState().currentProjectId; + const headers = new Headers(init.headers ?? {}); + if (projectId) { + headers.set("X-Project-ID", projectId); + } + return fetch(input, { ...init, headers }); +}; diff --git a/src/providers/data-provider/index.ts b/src/providers/data-provider/index.ts index a049c0e..fa69f05 100644 --- a/src/providers/data-provider/index.ts +++ b/src/providers/data-provider/index.ts @@ -1,7 +1,6 @@ "use client"; import dataProviderSimpleRest from "@refinedev/simple-rest"; +import { api, API_URL } from "@/lib/api"; -const API_URL = "https://api.fake-rest.refine.dev"; - -export const dataProvider = dataProviderSimpleRest(API_URL); +export const dataProvider = dataProviderSimpleRest(API_URL, api); diff --git a/src/store/projectStore.ts b/src/store/projectStore.ts new file mode 100644 index 0000000..5fda7d1 --- /dev/null +++ b/src/store/projectStore.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +interface ProjectState { + currentProjectId: string | null; + setCurrentProjectId: (id: string | null) => void; +} + +const getInitialProjectId = () => { + if (typeof window === "undefined") { + return null; + } + return localStorage.getItem("active_project"); +}; + +export const useProjectStore = create<ProjectState>((set) => ({ + currentProjectId: getInitialProjectId(), + setCurrentProjectId: (id) => { + if (typeof window !== "undefined") { + if (id) { + localStorage.setItem("active_project", id); + } else { + localStorage.removeItem("active_project"); + } + } + set({ currentProjectId: id }); + }, +})); -- 2.54.0 From 66f2390078c17dda93cea6b19bc86e26aa9e03bb Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Feb 2026 18:58:10 +0800 Subject: [PATCH 023/281] =?UTF-8?q?=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/_refine_context.tsx | 9 +- src/app/api/auth/[...nextauth]/options.ts | 23 ++- src/components/header/index.tsx | 6 +- .../OptimizationParameters.tsx | 7 +- src/components/olmap/SCADADataPanel.tsx | 6 +- src/components/olmap/SCADADeviceList.tsx | 6 +- src/components/project/ProjectSelector.tsx | 155 +++++++++++++----- src/contexts/ProjectContext.tsx | 22 ++- src/lib/api.ts | 24 ++- src/lib/apiFetch.ts | 24 ++- src/lib/authToken.ts | 51 ++++++ src/store/authStore.ts | 11 ++ src/types/next-auth.d.ts | 25 +++ 13 files changed, 307 insertions(+), 62 deletions(-) create mode 100644 src/lib/authToken.ts create mode 100644 src/store/authStore.ts create mode 100644 src/types/next-auth.d.ts diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index af98169..e88653d 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -8,13 +8,14 @@ import { } from "@refinedev/mui"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; -import React from "react"; +import React, { useEffect } from "react"; import routerProvider from "@refinedev/nextjs-router"; import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; import { ProjectProvider } from "@/contexts/ProjectContext"; +import { useAuthStore } from "@/store/authStore"; import { LiaNetworkWiredSolid } from "react-icons/lia"; import { TbDatabaseEdit } from "react-icons/tb"; @@ -47,6 +48,11 @@ type AppProps = { const App = (props: React.PropsWithChildren<AppProps>) => { const { data, status } = useSession(); const to = usePathname(); + const setAccessToken = useAuthStore((state) => state.setAccessToken); + + useEffect(() => { + setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null); + }, [data?.accessToken, setAccessToken]); if (status === "loading") { return <span>loading...</span>; @@ -103,6 +109,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => { if (data?.user) { const { user } = data; return { + id: user.id, name: user.name, avatar: user.image, }; diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 4e74744..0fb478e 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -1,7 +1,8 @@ +import { NextAuthOptions } from "next-auth"; import KeycloakProvider from "next-auth/providers/keycloak"; import Avatar from "@assets/avatar/avatar-small.jpeg"; -const authOptions = { +const authOptions: NextAuthOptions = { // Configure one or more authentication providers providers: [ KeycloakProvider({ @@ -19,6 +20,26 @@ const authOptions = { }), ], secret: process.env.NEXTAUTH_SECRET, + callbacks: { + jwt: async ({ token, profile, account }) => { + if (profile?.sub) { + token.sub = profile.sub; + } + if (account?.access_token) { + token.accessToken = account.access_token; + } + return token; + }, + session: async ({ session, token }) => { + if (session.user && token.sub) { + session.user.id = token.sub; + } + if (token.accessToken) { + session.accessToken = token.accessToken; + } + return session; + }, + }, }; export default authOptions; diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index ee70181..dfb23f9 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -25,9 +25,9 @@ import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; import { useProjectStore } from "@/store/projectStore"; type IUser = { - id: number; - name: string; - avatar: string; + id?: string; + name?: string; + avatar?: string; }; export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index 4c1cb67..3e75c1a 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -16,8 +16,8 @@ import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; type IUser = { - id: number; - name: string; + id: string; + name?: string; }; const OptimizationParameters: React.FC = () => { @@ -83,7 +83,7 @@ const OptimizationParameters: React.FC = () => { setAnalyzing(true); - if (!user || !user.name) { + if (!user || !user.id) { open?.({ type: "error", message: "用户信息无效", @@ -104,6 +104,7 @@ const OptimizationParameters: React.FC = () => { method: method, sensor_count: sensorCount, min_diameter: minDiameter, + user_id: user.id, user_name: user.name, }, } diff --git a/src/components/olmap/SCADADataPanel.tsx b/src/components/olmap/SCADADataPanel.tsx index a6c53dc..a788247 100644 --- a/src/components/olmap/SCADADataPanel.tsx +++ b/src/components/olmap/SCADADataPanel.tsx @@ -44,8 +44,8 @@ dayjs.extend(utc); dayjs.extend(timezone); type IUser = { - id: number; - name: string; + id: string; + name?: string; }; export interface TimeSeriesPoint { @@ -459,7 +459,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ return; } - if (!user || !user.name) { + if (!user || !user.id) { open?.({ type: "error", message: "用户信息无效,请重新登录", diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADADeviceList.tsx index 2c6f200..c8fdd1e 100644 --- a/src/components/olmap/SCADADeviceList.tsx +++ b/src/components/olmap/SCADADeviceList.tsx @@ -104,8 +104,8 @@ interface SCADADeviceListProps { } type IUser = { - id: number; - name: string; + id: string; + name?: string; }; const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ @@ -601,7 +601,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ return; } - if (!user || !user.name) { + if (!user || !user.id) { open?.({ type: "error", message: "用户信息无效,请重新登录", diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index b5fe3ec..00d3b8a 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -15,7 +15,9 @@ import { IconButton, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { apiFetch } from "@/lib/apiFetch"; +import { config, NETWORK_NAME } from "@/config/config"; interface ProjectSelectorProps { open: boolean; @@ -28,45 +30,96 @@ interface ProjectSelectorProps { onClose?: () => void; } -const PROJECTS = [ - { - id: "tjwater", - label: "默认", - workspace: "tjwater", - networkName: "tjwater", - extent: [13508802, 3608164, 13555651, 3633686], - }, - // { - // label: "苏州河", - // workspace: "szh", - // networkName: "szh", - // extent: [13490131, 3630016, 13525879, 3666969], - // }, - { - id: "test", - label: "测试项目", - workspace: "test", - networkName: "test", - extent: [13508849, 3608036, 13555781, 3633813], - }, -]; +type ProjectOption = { + id: string; + label: string; + workspace: string; + networkName: string; + extent: number[]; + description?: string | null; + status?: string | null; + projectRole?: string | null; +}; export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ open, onSelect, onClose, }) => { - const [projectId, setProjectId] = useState(PROJECTS[0].id); - const [workspace, setWorkspace] = useState(PROJECTS[0].workspace); - const [networkName, setNetworkName] = useState(PROJECTS[0].networkName); - const [extent, setExtent] = useState<number[]>( - PROJECTS[0].extent, - ); + const [projects, setProjects] = useState<ProjectOption[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState<string | null>(null); + const [projectId, setProjectId] = useState(""); + const [projectIdError, setProjectIdError] = useState<string | null>(null); + const [workspace, setWorkspace] = useState(config.MAP_WORKSPACE); + const [networkName, setNetworkName] = useState(NETWORK_NAME || "tjwater"); + const [extent, setExtent] = useState<number[]>(config.MAP_EXTENT); const [customMode, setCustomMode] = useState(false); + useEffect(() => { + const fetchProjects = async () => { + setIsLoading(true); + setLoadError(null); + try { + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/meta/projects`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const mapped: ProjectOption[] = Array.isArray(data) + ? data.map((item) => { + const bbox = Array.isArray(item.map_extent?.bbox) + ? item.map_extent.bbox.map((value: number) => Number(value)) + : null; + return { + id: item.project_id, + label: item.name || item.code || item.project_id, + workspace: item.gs_workspace || config.MAP_WORKSPACE, + networkName: item.code || NETWORK_NAME || config.MAP_WORKSPACE, + extent: + bbox && bbox.length === 4 ? bbox : config.MAP_EXTENT, + description: item.description, + status: item.status, + projectRole: item.project_role, + }; + }) + : []; + setProjects(mapped); + const savedProjectId = localStorage.getItem("active_project"); + const initial = + (savedProjectId && + mapped.find((project) => project.id === savedProjectId)) || + mapped[0]; + if (initial) { + setProjectId(initial.id); + setWorkspace(initial.workspace); + setNetworkName(initial.networkName); + setExtent(initial.extent); + setCustomMode(false); + } else { + setCustomMode(true); + } + } catch (error) { + console.error("Failed to load projects:", error); + setLoadError("项目列表加载失败,请使用自定义配置"); + setCustomMode(true); + } finally { + setIsLoading(false); + } + }; + + fetchProjects(); + }, []); + const handleConfirm = () => { - const resolvedProjectId = projectId.trim() || workspace || networkName; - onSelect(resolvedProjectId, workspace, networkName, extent); + if (!projectId.trim()) { + setProjectIdError("项目 ID 不能为空"); + return; + } + setProjectIdError(null); + onSelect(projectId.trim(), workspace, networkName, extent); }; return ( @@ -126,31 +179,46 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ <FormControl fullWidth variant="outlined"> <InputLabel>项目</InputLabel> <Select - value={workspace} + value={projectId} label="项目" onChange={(e) => { const val = e.target.value; if (val === "custom") { setCustomMode(true); - setProjectId(workspace); + setProjectIdError(null); } else { - const p = PROJECTS.find((p) => p.workspace === val); + const p = projects.find((p) => p.id === val); if (p) { setProjectId(p.id); setWorkspace(p.workspace); setNetworkName(p.networkName); setExtent(p.extent); + setProjectIdError(null); } } }} > - {PROJECTS.map((p) => ( - <MenuItem key={p.workspace} value={p.workspace}> + {projects.length === 0 && ( + <MenuItem value="" disabled> + <Typography variant="body2" color="text.secondary"> + {isLoading ? "正在加载项目..." : "暂无可用项目"} + </Typography> + </MenuItem> + )} + {projects.map((p) => ( + <MenuItem key={p.id} value={p.id}> <Box sx={{ display: "flex", flexDirection: "column" }}> <Typography variant="body1">{p.label}</Typography> <Typography variant="caption" color="text.secondary"> 工作区: {p.workspace} | 管网: {p.networkName} </Typography> + {(p.status || p.projectRole) && ( + <Typography variant="caption" color="text.secondary"> + {p.status ? `状态: ${p.status}` : ""} + {p.status && p.projectRole ? " | " : ""} + {p.projectRole ? `角色: ${p.projectRole}` : ""} + </Typography> + )} </Box> </MenuItem> ))} @@ -158,15 +226,26 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ <Typography variant="body1">自定义配置...</Typography> </MenuItem> </Select> + {loadError && ( + <Typography variant="caption" color="error"> + {loadError} + </Typography> + )} </FormControl> ) : ( <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <TextField label="项目 ID" value={projectId} - onChange={(e) => setProjectId(e.target.value)} + onChange={(e) => { + setProjectId(e.target.value); + setProjectIdError(null); + }} fullWidth - helperText="例如: tjwater" + helperText={ + projectIdError || "例如: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + error={Boolean(projectIdError)} /> <TextField label="Geoserver 工作区" diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 7eb214a..a128613 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -69,9 +69,25 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ setIsConfigured(true); try { - await apiFetch(`${config.BACKEND_URL}/openproject/?network=${net}`, { - method: "POST", - }); + const response = await apiFetch( + `${config.BACKEND_URL}/openproject/?network=${net}`, + { + method: "POST", + }, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const bbox = Array.isArray(data?.map_extent?.bbox) + ? data.map_extent.bbox.map((value: number) => Number(value)) + : null; + if (bbox && bbox.length === 4) { + setMapExtent(bbox); + localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", bbox.join(",")); + localStorage.removeItem(`${ws}_map_view`); + setCurrentProject((prev) => ({ ...prev, extent: bbox })); + } } catch (error) { console.error("Failed to open project:", error); } diff --git a/src/lib/api.ts b/src/lib/api.ts index cc7aea8..c2f7a33 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { config } from "@config/config"; import { useProjectStore } from "@/store/projectStore"; +import { getAccessToken } from "@/lib/authToken"; export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL; @@ -8,11 +9,26 @@ export const api = axios.create({ baseURL: API_URL, }); -api.interceptors.request.use((request) => { - const projectId = useProjectStore.getState().currentProjectId; - if (projectId) { +const isMetaProjectsRequest = (request: { + baseURL?: string; + url?: string; +}) => { + const url = `${request.baseURL ?? ""}${request.url ?? ""}`; + return url.includes("/api/v1/meta/projects"); +}; + +api.interceptors.request.use(async (request) => { + const accessToken = await getAccessToken(); + if (accessToken) { request.headers = request.headers ?? {}; - request.headers["X-Project-ID"] = projectId; + request.headers.Authorization = `Bearer ${accessToken}`; } + + const projectId = useProjectStore.getState().currentProjectId; + if (projectId && !isMetaProjectsRequest(request)) { + request.headers = request.headers ?? {}; + request.headers["X-Project-Id"] = projectId; + } + return request; }); diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts index 1c8c531..c303987 100644 --- a/src/lib/apiFetch.ts +++ b/src/lib/apiFetch.ts @@ -1,10 +1,28 @@ import { useProjectStore } from "@/store/projectStore"; +import { getAccessToken } from "@/lib/authToken"; -export const apiFetch = (input: RequestInfo | URL, init: RequestInit = {}) => { +const resolveUrl = (input: RequestInfo | URL) => { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + return ""; +}; + +const isMetaProjectsRequest = (input: RequestInfo | URL) => + resolveUrl(input).includes("/api/v1/meta/projects"); + +export const apiFetch = async ( + input: RequestInfo | URL, + init: RequestInit = {}, +) => { const projectId = useProjectStore.getState().currentProjectId; const headers = new Headers(init.headers ?? {}); - if (projectId) { - headers.set("X-Project-ID", projectId); + const accessToken = await getAccessToken(); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + if (projectId && !isMetaProjectsRequest(input)) { + headers.set("X-Project-Id", projectId); } return fetch(input, { ...init, headers }); }; diff --git a/src/lib/authToken.ts b/src/lib/authToken.ts new file mode 100644 index 0000000..ee0f15c --- /dev/null +++ b/src/lib/authToken.ts @@ -0,0 +1,51 @@ +import { getSession } from "next-auth/react"; +import { useAuthStore } from "@/store/authStore"; + +const decodeJwtPayload = (token: string) => { + const parts = token.split("."); + if (parts.length < 2) { + console.warn("Invalid JWT format."); + return null; + } + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + try { + const json = + typeof window !== "undefined" + ? window.atob(padded) + : Buffer.from(padded, "base64").toString("utf-8"); + return JSON.parse(json); + } catch (error) { + console.warn("Failed to decode JWT payload.", error); + return null; + } +}; + +const isTokenExpired = (token: string) => { + const payload = decodeJwtPayload(token); + if (!payload) { + return true; + } + if (typeof payload.exp !== "number") { + return false; + } + const now = Date.now(); + return now >= payload.exp * 1000 - 30_000; +}; + +export const getAccessToken = async () => { + const { accessToken, setAccessToken } = useAuthStore.getState(); + if (accessToken && !isTokenExpired(accessToken)) { + return accessToken; + } + if (accessToken) { + setAccessToken(null); + } + const session = await getSession(); + const token = typeof session?.accessToken === "string" ? session.accessToken : null; + if (token && !isTokenExpired(token)) { + setAccessToken(token); + return token; + } + return null; +}; diff --git a/src/store/authStore.ts b/src/store/authStore.ts new file mode 100644 index 0000000..3a6af58 --- /dev/null +++ b/src/store/authStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface AuthState { + accessToken: string | null; + setAccessToken: (token: string | null) => void; +} + +export const useAuthStore = create<AuthState>((set) => ({ + accessToken: null, + setAccessToken: (token) => set({ accessToken: token }), +})); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..233d00a --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,25 @@ +import "next-auth"; +import "next-auth/jwt"; + +declare module "next-auth" { + interface Session { + accessToken?: string; + user?: { + id?: string; + name?: string | null; + email?: string | null; + image?: string | null; + }; + } + + interface User { + id?: string; + } +} + +declare module "next-auth/jwt" { + interface JWT { + sub?: string; + accessToken?: string; + } +} -- 2.54.0 From f9dc4b74d05bdac09fe157f6d775cea2d1d2eaf0 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 27 Feb 2026 17:18:33 +0800 Subject: [PATCH 024/281] =?UTF-8?q?=E5=8F=98=E6=9B=B4=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env b/.env index b8260da..4ed338d 100644 --- a/.env +++ b/.env @@ -7,9 +7,9 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/" # 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn" NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" -NEXT_PUBLIC_MAP_WORKSPACE="szh" +NEXT_PUBLIC_MAP_WORKSPACE="tjwater" NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" # NEXT_PUBLIC_MAP_AVAILABLE_LAYERS="junctions, pipes, reservoirs, scada" -NEXT_PUBLIC_NETWORK_NAME="szh" +NEXT_PUBLIC_NETWORK_NAME="tjwater" NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg" NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec" \ No newline at end of file -- 2.54.0 From 2d27e803a3a416f5a7c83589ea845e86ccd6c235 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 27 Feb 2026 17:19:41 +0800 Subject: [PATCH 025/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=9C=AA=E8=AE=A4=E8=AF=81=E6=97=B6=EF=BC=8C=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E7=99=BB=E9=99=86=E7=8A=B6=E6=80=81=E5=8F=98=E6=9B=B4=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index c2f7a33..7e0ccf0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,8 @@ import axios from "axios"; import { config } from "@config/config"; import { useProjectStore } from "@/store/projectStore"; import { getAccessToken } from "@/lib/authToken"; +import { signOut } from "next-auth/react"; +import { useAuthStore } from "@/store/authStore"; export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL; @@ -9,6 +11,8 @@ export const api = axios.create({ baseURL: API_URL, }); +let isSigningOut = false; + const isMetaProjectsRequest = (request: { baseURL?: string; url?: string; @@ -32,3 +36,17 @@ api.interceptors.request.use(async (request) => { return request; }); + +api.interceptors.response.use( + (response) => response, + async (error) => { + if (error?.response?.status === 401 && typeof window !== "undefined") { + useAuthStore.getState().setAccessToken(null); + if (!isSigningOut) { + isSigningOut = true; + await signOut({ redirect: true, callbackUrl: "/login" }); + } + } + return Promise.reject(error); + }, +); -- 2.54.0 From 6c5862f7e42dbdf7307aded4ba98c727ece98aa6 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Mon, 2 Mar 2026 11:33:37 +0800 Subject: [PATCH 026/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dreact=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/Timeline.tsx | 43 ++++++++++-------- src/app/login/page.tsx | 12 ++--- .../olmap/HealthRiskAnalysis/Timeline.tsx | 44 ++++++++++--------- 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/src/app/OlMap/Controls/Timeline.tsx b/src/app/OlMap/Controls/Timeline.tsx index f7cc65f..208e984 100644 --- a/src/app/OlMap/Controls/Timeline.tsx +++ b/src/app/OlMap/Controls/Timeline.tsx @@ -605,14 +605,16 @@ const Timeline: React.FC<TimelineProps> = ({ sx={{ mb: 2, flexWrap: "wrap", gap: 1 }} > <Tooltip title="后退一天"> - <IconButton - color="primary" - onClick={handleDayStepBackward} - size="small" - disabled={disableDateSelection} - > - <FiSkipBack /> - </IconButton> + <span> + <IconButton + color="primary" + onClick={handleDayStepBackward} + size="small" + disabled={disableDateSelection} + > + <FiSkipBack /> + </IconButton> + </span> </Tooltip> {/* 日期选择器 */} <DatePicker @@ -633,17 +635,20 @@ const Timeline: React.FC<TimelineProps> = ({ disabled={disableDateSelection} /> <Tooltip title="前进一天"> - <IconButton - color="primary" - onClick={handleDayStepForward} - size="small" - disabled={ - disableDateSelection || - selectedDate.toDateString() === new Date().toDateString() - } - > - <FiSkipForward /> - </IconButton> + <span> + <IconButton + color="primary" + onClick={handleDayStepForward} + size="small" + disabled={ + disableDateSelection || + selectedDate.toDateString() === + new Date().toDateString() + } + > + <FiSkipForward /> + </IconButton> + </span> </Tooltip> {/* 播放控制按钮 */} <Box sx={{ display: "flex", gap: 1 }} className="ml-4"> diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e604017..ee6f578 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -5,7 +5,7 @@ import Button from "@mui/material/Button"; import Container from "@mui/material/Container"; import Typography from "@mui/material/Typography"; import { useLogin } from "@refinedev/core"; -import { ThemedTitle } from "@refinedev/mui"; +import { Title } from "@components/title"; export default function Login() { const { mutate: login } = useLogin(); @@ -25,13 +25,9 @@ export default function Login() { justifyContent="center" flexDirection="column" > - <ThemedTitle - collapsed={false} - wrapperStyles={{ - fontSize: "22px", - justifyContent: "center", - }} - /> + <Box display="flex" justifyContent="center"> + <Title collapsed={false} /> + </Box> <Button style={{ width: "240px" }} size="large" diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index 957a99b..13ff443 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -485,14 +485,16 @@ const Timeline: React.FC<TimelineProps> = ({ sx={{ mb: 2, flexWrap: "wrap", gap: 1 }} > <Tooltip title="后退一天"> - <IconButton - color="primary" - onClick={handleDayStepBackward} - size="small" - disabled={disableDateSelection} - > - <FiSkipBack /> - </IconButton> + <span> + <IconButton + color="primary" + onClick={handleDayStepBackward} + size="small" + disabled={disableDateSelection} + > + <FiSkipBack /> + </IconButton> + </span> </Tooltip> {/* 日期时间选择器 */} <DateTimePicker @@ -515,18 +517,20 @@ const Timeline: React.FC<TimelineProps> = ({ ampm={false} /> <Tooltip title="前进一天"> - <IconButton - color="primary" - onClick={handleDayStepForward} - size="small" - disabled={ - disableDateSelection || - selectedDateTime.toDateString() === - new Date().toDateString() - } - > - <FiSkipForward /> - </IconButton> + <span> + <IconButton + color="primary" + onClick={handleDayStepForward} + size="small" + disabled={ + disableDateSelection || + selectedDateTime.toDateString() === + new Date().toDateString() + } + > + <FiSkipForward /> + </IconButton> + </span> </Tooltip> {/* 播放控制按钮 */} <Box sx={{ display: "flex", gap: 1 }} className="ml-4"> -- 2.54.0 From cd34e511acdb728acad7e154dde8c5024c801a94 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Mon, 2 Mar 2026 11:34:07 +0800 Subject: [PATCH 027/281] =?UTF-8?q?=E6=9C=AA=E8=AE=A4=E8=AF=81=E6=97=B6?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E7=99=BB=E5=BD=95=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/apiFetch.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts index c303987..08cdf49 100644 --- a/src/lib/apiFetch.ts +++ b/src/lib/apiFetch.ts @@ -1,5 +1,9 @@ import { useProjectStore } from "@/store/projectStore"; import { getAccessToken } from "@/lib/authToken"; +import { signOut } from "next-auth/react"; +import { useAuthStore } from "@/store/authStore"; + +let isSigningOut = false; const resolveUrl = (input: RequestInfo | URL) => { if (typeof input === "string") return input; @@ -24,5 +28,16 @@ export const apiFetch = async ( if (projectId && !isMetaProjectsRequest(input)) { headers.set("X-Project-Id", projectId); } - return fetch(input, { ...init, headers }); + + const response = await fetch(input, { ...init, headers }); + + if (response.status === 401 && typeof window !== "undefined") { + useAuthStore.getState().setAccessToken(null); + if (!isSigningOut) { + isSigningOut = true; + await signOut({ redirect: true, callbackUrl: "/login" }); + } + } + + return response; }; -- 2.54.0 From b73481d604380e302f899b2c5de9a1014c69693c Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Thu, 5 Mar 2026 11:33:09 +0800 Subject: [PATCH 028/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=AE=A1=E9=81=93?= =?UTF-8?q?=E5=86=B2=E6=B4=97=E9=9D=A2=E6=9D=BF=E7=9A=84=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FlushingAnalysis/AnalysisParameters.tsx | 2 +- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 43 ++++++++++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 241e666..32e2f8f 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -426,7 +426,7 @@ const AnalysisParameters: React.FC = () => { </Box> </Box> - <Box className="mt-2"> + <Box className="mt-auto pt-2"> <Button fullWidth variant="contained" diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index ed927a6..fa51752 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -19,7 +19,6 @@ import { } from "@mui/material"; import { Info as InfoIcon, - Search as SearchIcon, LocationOn as LocationIcon, } from "@mui/icons-material"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; @@ -333,7 +332,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ slotProps={{ textField: { size: "small", - sx: { width: 160 }, + sx: { width: 200 }, }, }} /> @@ -344,10 +343,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ onClick={handleQuery} disabled={loading} size="small" - startIcon={<SearchIcon />} className="bg-blue-600 hover:bg-blue-700" + sx={{ minWidth: 80 }} > - 查询 + {loading ? "查询中..." : "查询"} </Button> </Box> </Box> @@ -356,11 +355,41 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <Box className="flex-1 overflow-auto"> {schemes.length === 0 ? ( <Box className="flex flex-col items-center justify-center h-full text-gray-400"> - <Typography variant="body2">暂无方案数据</Typography> + <Box className="mb-4"> + <svg + width="80" + height="80" + viewBox="0 0 80 80" + fill="none" + className="opacity-40" + > + <rect + x="10" + y="20" + width="60" + height="45" + rx="2" + stroke="currentColor" + strokeWidth="2" + /> + <line + x1="10" + y1="30" + x2="70" + y2="30" + stroke="currentColor" + strokeWidth="2" + /> + </svg> + </Box> + <Typography variant="body2">总共 0 条</Typography> + <Typography variant="body2" className="mt-1"> + No data + </Typography> </Box> ) : ( - <Box className="space-y-2 p-1"> - <Typography variant="caption" className="text-gray-500 px-1"> + <Box className="space-y-2 p-2"> + <Typography variant="caption" className="text-gray-500 px-2"> 共 {schemes.length} 条记录 </Typography> {schemes.map((scheme) => ( -- 2.54.0 From 377fc32f4c003b734e989c9719da3613166e340b Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 6 Mar 2026 09:59:06 +0800 Subject: [PATCH 029/281] =?UTF-8?q?=E5=AE=9E=E7=8E=B0DMA=E6=BC=8F=E6=8D=9F?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E9=9D=A2=E6=9D=BF=E6=95=B4=E4=BD=93=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dma-leak-detection/loading.tsx | 5 + .../dma-leak-detection/page.tsx | 20 + src/app/OlMap/Controls/StyleLegend.tsx | 91 ++-- src/app/_refine_context.tsx | 9 + .../ContaminantSimulation/ResultsPanel.tsx | 30 -- .../WaterQualityPanel.tsx | 5 - .../DMALeakDetection/AnalysisParameters.tsx | 260 +++++++++++ .../DMALeakDetectionPanel.tsx | 432 ++++++++++++++++++ .../olmap/DMALeakDetection/SchemeQuery.tsx | 259 +++++++++++ .../olmap/DMALeakDetection/types.ts | 53 +++ 10 files changed, 1096 insertions(+), 68 deletions(-) create mode 100644 src/app/(main)/hydraulic-simulation/dma-leak-detection/loading.tsx create mode 100644 src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx delete mode 100644 src/components/olmap/ContaminantSimulation/ResultsPanel.tsx create mode 100644 src/components/olmap/DMALeakDetection/AnalysisParameters.tsx create mode 100644 src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx create mode 100644 src/components/olmap/DMALeakDetection/SchemeQuery.tsx create mode 100644 src/components/olmap/DMALeakDetection/types.ts diff --git a/src/app/(main)/hydraulic-simulation/dma-leak-detection/loading.tsx b/src/app/(main)/hydraulic-simulation/dma-leak-detection/loading.tsx new file mode 100644 index 0000000..2c57921 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/dma-leak-detection/loading.tsx @@ -0,0 +1,5 @@ +import { MapSkeleton } from "@components/loading/MapSkeleton"; + +export default function Loading() { + return <MapSkeleton />; +} diff --git a/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx b/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx new file mode 100644 index 0000000..fc90a12 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import MapComponent from "@app/OlMap/MapComponent"; +import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"; + +export default function Home() { + return ( + <div className="relative w-full h-full overflow-hidden"> + <MapComponent> + <MapToolbar + queryType="scheme" + schemeType="dma_leak_identification" + hiddenButtons={["style"]} + /> + <DMALeakDetectionPanel /> + </MapComponent> + </div> + ); +} diff --git a/src/app/OlMap/Controls/StyleLegend.tsx b/src/app/OlMap/Controls/StyleLegend.tsx index e374e84..a1bffe8 100644 --- a/src/app/OlMap/Controls/StyleLegend.tsx +++ b/src/app/OlMap/Controls/StyleLegend.tsx @@ -10,6 +10,9 @@ interface LegendStyleConfig { type: string; // 图例类型 dimensions: number[]; // 尺寸大小 breaks: number[]; // 分段值 + labels?: string[]; // 可选标签(用于离散分类) + columns?: number; + itemsPerColumn?: number; } // 图例组件 // 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息 @@ -24,6 +27,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({ type, // 图例类型 dimensions, breaks, + labels, + columns = 1, + itemsPerColumn, }) => { return ( <Box @@ -33,9 +39,26 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({ <Typography variant="subtitle2" gutterBottom> {layerName} - {property} </Typography> - {[...Array(breaks.length)].map((_, index) => { - const color = colors[index]; // 默认颜色为黑色 - const dimension = dimensions[index]; // 默认尺寸为16 + <Box + sx={{ + display: "grid", + gridTemplateColumns: + itemsPerColumn && itemsPerColumn > 0 + ? undefined + : `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`, + gridTemplateRows: + itemsPerColumn && itemsPerColumn > 0 + ? `repeat(${itemsPerColumn}, minmax(0, auto))` + : undefined, + gridAutoFlow: + itemsPerColumn && itemsPerColumn > 0 ? "column" : undefined, + columnGap: 1.5, + rowGap: 0.5, + }} + > + {[...Array(breaks.length)].map((_, index) => { + const color = colors[index]; // 默认颜色为黑色 + const dimension = dimensions[index]; // 默认尺寸为16 // // 处理第一个区间(小于 breaks[0]) // if (index === 0) { @@ -66,37 +89,39 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({ // } // 处理中间区间(breaks[index] - breaks[index + 1]) - if (index + 1 < breaks.length) { - const prevValue = breaks[index]; - const currentValue = breaks[index + 1]; - return ( - <Box key={index} className="flex items-center gap-2 mb-1"> - <Box - sx={ - type === "point" - ? { - width: dimension, - height: dimension, - borderRadius: "50%", - backgroundColor: color, - } - : { - width: 16, - height: dimension, - backgroundColor: color, - border: `1px solid ${color}`, - } - } - /> - <Typography variant="caption" className="text-xs"> - {prevValue?.toFixed(1)} - {currentValue?.toFixed(1)} - </Typography> - </Box> - ); - } + if (index + 1 < breaks.length) { + const prevValue = breaks[index]; + const currentValue = breaks[index + 1]; + return ( + <Box key={index} className="flex items-center gap-2"> + <Box + sx={ + type === "point" + ? { + width: dimension, + height: dimension, + borderRadius: "50%", + backgroundColor: color, + } + : { + width: 16, + height: dimension, + backgroundColor: color, + border: `1px solid ${color}`, + } + } + /> + <Typography variant="caption" className="text-xs"> + {labels?.[index] ?? + `${prevValue?.toFixed(1)} - ${currentValue?.toFixed(1)}`} + </Typography> + </Box> + ); + } - return null; - })} + return null; + })} + </Box> </Box> ); }; diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index e88653d..282ea2a 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -180,6 +180,15 @@ const App = (props: React.PropsWithChildren<AppProps>) => { label: "爆管分析定位", }, }, + { + name: "DMA漏损识别", + list: "/hydraulic-simulation/dma-leak-detection", + meta: { + parent: "Hydraulic Simulation", + icon: <TbLocationPin className="w-6 h-6" />, + label: "DMA漏损识别", + }, + }, { name: "水质模拟", list: "/hydraulic-simulation/water-quality-simulation", diff --git a/src/components/olmap/ContaminantSimulation/ResultsPanel.tsx b/src/components/olmap/ContaminantSimulation/ResultsPanel.tsx deleted file mode 100644 index 92aae8e..0000000 --- a/src/components/olmap/ContaminantSimulation/ResultsPanel.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import React from "react"; -import { Box, Typography } from "@mui/material"; - -interface ResultsPanelProps { - schemeName?: string; -} - -const ResultsPanel: React.FC<ResultsPanelProps> = ({ schemeName }) => { - return ( - <Box className="flex flex-col h-full"> - <Box className="flex-1 overflow-auto bg-white rounded border border-gray-200 p-5"> - <Typography variant="h6" className="font-semibold text-gray-900"> - 水质模拟结果 - </Typography> - <Typography variant="body2" className="text-gray-600 mt-2"> - 请在下方时间轴查看各时刻的水质分布。 - </Typography> - {schemeName && ( - <Typography variant="caption" className="text-gray-500 mt-4 block"> - 当前方案:{schemeName} - </Typography> - )} - </Box> - </Box> - ); -}; - -export default ResultsPanel; diff --git a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx index 9450e9f..43c7023 100644 --- a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx +++ b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx @@ -19,7 +19,6 @@ import { } from "@mui/icons-material"; import ContaminantAnalysisParameters from "./AnalysisParameters"; import ContaminantSchemeQuery from "./SchemeQuery"; -import ContaminantResultsPanel from "./ResultsPanel"; import { useData } from "@app/OlMap/MapComponent"; interface WaterQualityPanelProps { @@ -175,10 +174,6 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({ <TabPanel value={currentTab} index={1}> <ContaminantSchemeQuery onViewResults={() => setCurrentTab(2)} /> </TabPanel> - - <TabPanel value={currentTab} index={2}> - <ContaminantResultsPanel schemeName={data?.schemeName} /> - </TabPanel> </Box> </Drawer> </> diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx new file mode 100644 index 0000000..a19bd44 --- /dev/null +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -0,0 +1,260 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Collapse, + TextField, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +import { useNotification } from "@refinedev/core"; +import { api } from "@/lib/api"; +import { NETWORK_NAME, config } from "@config/config"; +import { LeakageResultDetail } from "./types"; + +interface Props { + onResult: (result: LeakageResultDetail) => void; +} + +const AnalysisParameters: React.FC<Props> = ({ onResult }) => { + const { open } = useNotification(); + const [schemeName, setSchemeName] = useState(`DMA_Leak_${Date.now()}`); + const [dmaCount, setDmaCount] = useState<number>(5); + const [startTime, setStartTime] = useState<Dayjs | null>( + dayjs().subtract(2, "hour"), + ); + const [endTime, setEndTime] = useState<Dayjs | null>(dayjs()); + const [popSize, setPopSize] = useState<number>(50); + const [maxGen, setMaxGen] = useState<number>(100); + const [qSum, setQSum] = useState<number>(0.4); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [running, setRunning] = useState(false); + + const isValid = useMemo(() => { + if (!schemeName.trim() || !startTime || !endTime) return false; + return startTime.isBefore(endTime) && qSum >= 0.1; + }, [schemeName, startTime, endTime, qSum]); + + const handleRun = async () => { + if (!isValid || !startTime || !endTime) { + open?.({ type: "error", message: "请完善参数并确认时间范围合法" }); + return; + } + setRunning(true); + open?.({ + key: "dma-leak-analysis", + type: "progress", + message: "方案提交分析中", + undoableTimeout: 3, + }); + try { + const response = await api.post( + `${config.BACKEND_URL}/api/v1/leakage/identify/`, + { + network: NETWORK_NAME, + scheme_name: schemeName.trim(), + dma_count: dmaCount, + scada_start: startTime.toISOString(), + scada_end: endTime.toISOString(), + pop_size: popSize, + max_gen: maxGen, + q_sum: qSum, + q_sum_unit: "m3/s", + output_flow_unit: "m3/s", + }, + ); + onResult(response.data as LeakageResultDetail); + open?.({ + key: "dma-leak-analysis", + type: "success", + message: "方案分析成功", + description: "DMA漏损识别完成,请在方案查询中查看结果。", + }); + } catch (error: any) { + open?.({ + key: "dma-leak-analysis", + type: "error", + message: "提交分析失败", + description: error?.response?.data?.detail ?? "请求失败", + }); + } finally { + setRunning(false); + } + }; + + return ( + <Box className="flex flex-col flex-1 min-h-0"> + <Box className="flex flex-col gap-3"> + <Alert severity="info"> + 漏损识别耗时较长(DMA 数量越多越慢),建议先用较小 DMA 数量试跑。 + </Alert> + + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 方案名称 + </Typography> + <TextField + value={schemeName} + onChange={(e) => setSchemeName(e.target.value)} + placeholder="请输入方案名称" + fullWidth + size="small" + /> + </Box> + + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + DMA 数量 + </Typography> + <TextField + type="number" + value={dmaCount} + onChange={(e) => { + const value = Number.parseInt(e.target.value, 10); + setDmaCount(Number.isNaN(value) ? 5 : Math.max(3, value)); + }} + fullWidth + size="small" + inputProps={{ min: 3, step: 1 }} + /> + </Box> + + <LocalizationProvider + dateAdapter={AdapterDayjs} + adapterLocale="zh-cn" + localeText={ + pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText + } + > + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + SCADA 开始时间 + </Typography> + <DateTimePicker + value={startTime} + onChange={setStartTime} + maxDateTime={endTime ?? undefined} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + SCADA 结束时间 + </Typography> + <DateTimePicker + value={endTime} + onChange={setEndTime} + minDateTime={startTime ?? undefined} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + </LocalizationProvider> + + <Box className="flex flex-col gap-2"> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 总漏损流量 (m3/s) + </Typography> + <TextField + type="number" + size="small" + value={qSum} + onChange={(e) => { + const value = Number(e.target.value); + setQSum(Number.isNaN(value) ? 0.4 : Math.max(0.1, value)); + }} + inputProps={{ min: 0.1, step: 0.1 }} + /> + <Box + sx={{ + border: "1px solid", + borderColor: "grey.200", + borderRadius: 1, + overflow: "hidden", + }} + > + <Box + role="button" + tabIndex={0} + onClick={() => setAdvancedOpen((prev) => !prev)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setAdvancedOpen((prev) => !prev); + }} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + px: 1.25, + py: 0.75, + cursor: "pointer", + backgroundColor: "transparent", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + <Typography variant="body2" color="text.secondary"> + 高级选项 + </Typography> + <ExpandMoreIcon + sx={{ + transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)", + transition: "transform 0.2s ease", + }} + /> + </Box> + <Collapse in={advancedOpen} timeout="auto" unmountOnExit> + <Box + sx={{ + px: 1.25, + pt: 1.25, + pb: 1.25, + backgroundColor: "transparent", + }} + > + <Box className="grid grid-cols-2 gap-2"> + <TextField + type="number" + label="种群规模" + size="small" + value={popSize} + onChange={(e) => setPopSize(Number(e.target.value))} + /> + <TextField + type="number" + label="最大代数" + size="small" + value={maxGen} + onChange={(e) => setMaxGen(Number(e.target.value))} + /> + </Box> + </Box> + </Collapse> + </Box> + </Box> + </Box> + + <Box className="mt-auto pt-3"> + <Button + fullWidth + variant="contained" + onClick={handleRun} + disabled={!isValid || running} + className="bg-blue-600 hover:bg-blue-700" + > + {running ? "识别中..." : "开始识别"} + </Button> + </Box> + </Box> + ); +}; + +export default AnalysisParameters; diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx new file mode 100644 index 0000000..6c1e3de --- /dev/null +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -0,0 +1,432 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Box, + Drawer, + Tabs, + Tab, + Typography, + IconButton, + Tooltip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Chip, +} from "@mui/material"; +import { + Analytics as AnalyticsIcon, + Search as SearchIcon, + ChevronLeft, + ChevronRight, + FormatListBulleted, +} from "@mui/icons-material"; +import dayjs from "dayjs"; +import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import Feature from "ol/Feature"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { useMap } from "@app/OlMap/MapComponent"; +import StyleLegend from "@app/OlMap/Controls/StyleLegend"; +import AnalysisParameters from "./AnalysisParameters"; +import SchemeQuery from "./SchemeQuery"; +import { LeakageResultDetail } from "./types"; + +const TabPanel = ({ + value, + index, + children, +}: { + value: number; + index: number; + children: React.ReactNode; +}) => ( + <div role="tabpanel" hidden={value !== index} className="flex-1 overflow-hidden flex flex-col"> + {value === index ? <Box className="flex-1 overflow-auto p-4 flex flex-col">{children}</Box> : null} + </div> +); + +const AREA_COLORS = [ + "#2563eb", + "#7c3aed", + "#0891b2", + "#16a34a", + "#ca8a04", + "#dc2626", + "#ea580c", + "#0f766e", + "#4338ca", + "#be123c", +]; + +const getAreaColor = (areaId: string | number | undefined) => { + const text = String(areaId ?? ""); + let hash = 0; + for (let i = 0; i < text.length; i += 1) { + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + } + return AREA_COLORS[hash % AREA_COLORS.length]; +}; + +const DMALeakDetectionPanel: React.FC = () => { + const map = useMap(); + const [open, setOpen] = useState(true); + const [tab, setTab] = useState(0); + const [result, setResult] = useState<LeakageResultDetail | null>(null); + const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null); + const [nodeLayer, setNodeLayer] = useState<VectorLayer<VectorSource> | null>(null); + + const sortedRows = useMemo(() => { + if (!result?.rows) return []; + return [...result.rows].sort( + (a, b) => b.LeakageFlow_m3_per_s - a.LeakageFlow_m3_per_s, + ); + }, [result]); + const drawerWidth = 450; + const panelTitle = "DMA漏损识别"; + const activeAreas = loadedResult?.areas ?? []; + const legendColors = useMemo( + () => activeAreas.map((area) => getAreaColor(area.area_id)), + [activeAreas], + ); + const legendLabels = useMemo( + () => activeAreas.map((area) => `区域 ${area.area_id}`), + [activeAreas], + ); + const legendBreaks = useMemo( + () => Array.from({ length: activeAreas.length + 1 }, (_, i) => i + 1), + [activeAreas.length], + ); + + useEffect(() => { + if (!map) return; + const layer = new VectorLayer({ + source: new VectorSource(), + maxZoom: 24, + minZoom: 12, + properties: { + name: "DMA漏损节点着色", + value: "dma_leak_nodes", + }, + style: (feature) => { + const areaId = feature.get("__areaId"); + return new Style({ + image: new CircleStyle({ + radius: 4.5, + fill: new Fill({ color: getAreaColor(areaId) }), + stroke: new Stroke({ color: "#ffffff", width: 1.2 }), + }), + }); + }, + }); + map.addLayer(layer); + setNodeLayer(layer); + return () => { + map.removeLayer(layer); + }; + }, [map]); + + useEffect(() => { + if (!nodeLayer) return; + const source = nodeLayer.getSource(); + if (!source) return; + source.clear(); + if (!loadedResult) return; + + const nodeAreaMap = loadedResult.node_area_map || {}; + const nodeIds = Object.keys(nodeAreaMap); + if (nodeIds.length === 0) return; + + queryFeaturesByIds(nodeIds, "geo_junctions_mat").then((features) => { + if (!features?.length) return; + features.forEach((feature) => { + const nodeId = String(feature.get("id") ?? ""); + feature.set("__areaId", nodeAreaMap[nodeId] ?? ""); + }); + source.addFeatures(features as Feature[]); + }); + }, [loadedResult, nodeLayer]); + + const handleAnalysisResult = useCallback((res: LeakageResultDetail) => { + setResult(res); + }, []); + + const handleViewResult = useCallback((res: LeakageResultDetail) => { + setResult(res); + setLoadedResult(res); + setTab(2); + }, []); + + return ( + <> + {!open && ( + <Box + className="absolute top-4 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100" + onClick={() => setOpen(true)} + sx={{ zIndex: 1300 }} + > + <Box className="flex flex-col items-center py-3 px-3 gap-1"> + <AnalyticsIcon className="text-[#257DD4] w-5 h-5" /> + <Typography + variant="caption" + className="text-gray-700 font-semibold my-1 text-xs" + style={{ writingMode: "vertical-rl" }} + > + {panelTitle} + </Typography> + <ChevronLeft className="text-gray-600 w-4 h-4" /> + </Box> + </Box> + )} + <Drawer + anchor="right" + open={open} + variant="persistent" + hideBackdrop + sx={{ + width: 0, + flexShrink: 0, + "& .MuiDrawer-paper": { + width: drawerWidth, + boxSizing: "border-box", + position: "absolute", + top: 16, + right: 16, + height: "calc(100vh - 32px)", + maxHeight: "850px", + borderRadius: "12px", + boxShadow: + "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", + backdropFilter: "blur(8px)", + opacity: 0.95, + transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out", + border: "none", + "&:hover": { + opacity: 1, + }, + }, + }} + > + <Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden"> + <Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white"> + <Box className="flex items-center gap-2"> + <AnalyticsIcon className="w-5 h-5" /> + <Typography variant="h6" className="text-lg font-semibold"> + {panelTitle} + </Typography> + </Box> + <Tooltip title="收起"> + <IconButton + size="small" + onClick={() => setOpen(false)} + sx={{ color: "primary.contrastText" }} + > + <ChevronRight fontSize="small" /> + </IconButton> + </Tooltip> + </Box> + <Box className="border-b border-gray-200 bg-white"> + <Tabs + value={tab} + onChange={(_, v) => setTab(v)} + variant="fullWidth" + sx={{ + minHeight: 48, + "& .MuiTab-root": { + minHeight: 48, + textTransform: "none", + fontSize: "0.875rem", + fontWeight: 500, + transition: "all 0.2s", + }, + "& .Mui-selected": { + color: "#257DD4", + }, + "& .MuiTabs-indicator": { + backgroundColor: "#257DD4", + }, + }} + > + <Tab icon={<AnalyticsIcon fontSize="small" />} iconPosition="start" label="识别参数" /> + <Tab icon={<SearchIcon fontSize="small" />} iconPosition="start" label="方案查询" /> + <Tab icon={<FormatListBulleted fontSize="small" />} iconPosition="start" label="识别结果" /> + </Tabs> + </Box> + <TabPanel value={tab} index={0}> + <AnalysisParameters onResult={handleAnalysisResult} /> + </TabPanel> + <TabPanel value={tab} index={1}> + <SchemeQuery onViewResult={handleViewResult} /> + </TabPanel> + <TabPanel value={tab} index={2}> + {!result || !sortedRows.length ? ( + <Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4"> + <Box className="mb-4"> + <svg width="80" height="80" viewBox="0 0 80 80" fill="none" className="opacity-40"> + <rect x="10" y="20" width="60" height="45" rx="2" stroke="currentColor" strokeWidth="2" /> + <line x1="10" y1="30" x2="70" y2="30" stroke="currentColor" strokeWidth="2" /> + </svg> + </Box> + <Typography variant="body2">暂无识别结果</Typography> + <Typography variant="body2" className="mt-1"> + 请先加载方案或执行识别分析 + </Typography> + </Box> + ) : ( + <Box className="h-full overflow-auto p-1"> + {/* 方案详情卡片 */} + <Box className="mb-4 space-y-3"> + <Box className="flex items-center justify-between px-1"> + <Box className="flex items-center gap-2"> + <Box className="w-1 h-4 bg-blue-600 rounded-full" /> + <Typography + variant="h6" + className="font-bold text-gray-900 truncate" + sx={{ fontSize: "1.1rem" }} + title={result.scheme_name || ""} + > + {result.scheme_name || "漏损识别结果"} + </Typography> + </Box> + {result.username && ( + <Chip + label={result.username} + size="small" + sx={{ + height: 24, + backgroundColor: "#f3f4f6", + color: "#4b5563", + border: "none", + fontWeight: 500 + }} + /> + )} + </Box> + + <Box className="grid grid-cols-2 gap-3"> + {/* 方案时间 */} + <Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm"> + <Typography variant="caption" className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> + 方案时间 + </Typography> + <Typography variant="body2" className="font-bold text-blue-900"> + {dayjs(result.scheme_start_time || result.create_time).format("MM-DD HH:mm")} + </Typography> + </Box> + + {/* 总漏损流量 */} + <Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm"> + <Typography variant="caption" className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> + 总漏损流量 + </Typography> + <Typography variant="body2" className="font-bold text-orange-900"> + {(() => { + const val = (result.scheme_detail as any)?.algorithm_params?.q_sum; + const unit = (result.scheme_detail as any)?.algorithm_params?.q_sum_unit || "m3/s"; + return val !== undefined ? `${Number(val).toFixed(3)} ${unit}` : "-"; + })()} + </Typography> + </Box> + + {/* 分区数量 */} + <Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm"> + <Typography variant="caption" className="text-green-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> + 分区数量 + </Typography> + <Typography variant="body2" className="font-bold text-green-900"> + {(result.scheme_detail as any)?.result_summary?.area_count ?? result.areas?.length ?? 0} 个 + </Typography> + </Box> + + {/* 最大漏损 */} + <Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm"> + <Typography variant="caption" className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> + 最大漏损 + </Typography> + <Typography variant="body2" className="font-bold text-purple-900"> + {(() => { + const maxL = (result.scheme_detail as any)?.result_summary?.max_leakage; + return maxL !== undefined ? `${Number(maxL).toFixed(3)} m3/s` : "-"; + })()} + </Typography> + </Box> + </Box> + </Box> + + {/* 漏损列表 */} + <Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden"> + <Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white"> + <Box className="flex items-center gap-2"> + <FormatListBulleted className="text-blue-600 w-5 h-5" /> + <Typography variant="subtitle1" className="font-bold text-gray-800"> + 区域漏损列表 + </Typography> + </Box> + <Chip + size="small" + label={`${sortedRows.length} 条`} + sx={{ + height: 22, + backgroundColor: "rgba(37, 99, 235, 0.08)", + color: "#2563eb", + fontWeight: 600, + fontSize: "0.75rem", + border: "none" + }} + /> + </Box> + <Table size="small"> + <TableHead> + <TableRow sx={{ backgroundColor: "#f8fafc" }}> + <TableCell sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pl: 3 }}>区域</TableCell> + <TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }}>漏损量占比 (%)</TableCell> + <TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }}>漏损量 (m3/s)</TableCell> + </TableRow> + </TableHead> + <TableBody> + {sortedRows.map((row) => ( + <TableRow key={row.Area} hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}> + <TableCell sx={{ pl: 3, py: 1.2 }}> + <Box className="flex items-center gap-2"> + <Box className="w-2 h-2 rounded-full" sx={{ backgroundColor: getAreaColor(row.Area) }} /> + <Typography variant="body2" className="font-medium text-gray-700"> + {row.Area} + </Typography> + </Box> + </TableCell> + <TableCell align="right" sx={{ py: 1.2, color: "#475569" }}>{(row.LeakageRatio * 100).toFixed(3)}</TableCell> + <TableCell align="right" sx={{ pr: 3, py: 1.2, fontWeight: 500, color: "#334155" }}>{row.LeakageFlow_m3_per_s.toFixed(3)}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </Box> + </Box> + )} + </TabPanel> + </Box> + </Drawer> + + {loadedResult && activeAreas.length > 0 && ( + <Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10"> + <StyleLegend + layerName="节点" + layerId="dma-leakage" + property="区域" + colors={legendColors} + type="point" + dimensions={Array(legendColors.length).fill(10)} + breaks={legendBreaks} + labels={legendLabels} + itemsPerColumn={5} + /> + </Box> + )} + </> + ); +}; + +export default DMALeakDetectionPanel; diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx new file mode 100644 index 0000000..e7c6f91 --- /dev/null +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -0,0 +1,259 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + Chip, + Collapse, + FormControlLabel, + Checkbox, + IconButton, + Tooltip, + Typography, +} from "@mui/material"; +import { Info as InfoIcon } from "@mui/icons-material"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import "dayjs/locale/zh-cn"; +import dayjs, { Dayjs } from "dayjs"; +import { useNotification } from "@refinedev/core"; +import { api } from "@/lib/api"; +import { NETWORK_NAME, config } from "@config/config"; +import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; + +interface Props { + onViewResult: (result: LeakageResultDetail) => void; +} + +const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { + const { open } = useNotification(); + const [queryAll, setQueryAll] = useState(true); + const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); + const [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState<number | null>(null); + + const handleQuery = async () => { + setLoading(true); + try { + const params: Record<string, string> = { network: NETWORK_NAME }; + if (!queryAll && queryDate) { + params.query_date = queryDate.startOf("day").toISOString(); + } + const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, { + params, + }); + setSchemes(response.data); + } catch (error: any) { + open?.({ + type: "error", + message: "查询失败", + description: error?.response?.data?.detail ?? "无法获取方案列表", + }); + } finally { + setLoading(false); + } + }; + + const handleViewSchemeResult = async (schemeName: string) => { + try { + const response = await api.get( + `${config.BACKEND_URL}/api/v1/leakage/schemes/${encodeURIComponent(schemeName)}`, + { params: { network: NETWORK_NAME } }, + ); + onViewResult(response.data as LeakageResultDetail); + } catch (error: any) { + open?.({ + type: "error", + message: "查看详情失败", + description: error?.response?.data?.detail ?? "无法获取方案详情", + }); + } + }; + + return ( + <Box className="flex flex-col h-full"> + <Box className="mb-2 p-2 bg-gray-50 rounded"> + <Box className="flex items-center gap-2 justify-between"> + <Box className="flex items-center gap-2"> + <FormControlLabel + control={ + <Checkbox + size="small" + checked={queryAll} + onChange={(e) => setQueryAll(e.target.checked)} + /> + } + label={<Typography variant="body2">查询全部</Typography>} + className="m-0" + /> + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> + <DatePicker + value={queryDate} + onChange={setQueryDate} + disabled={queryAll} + format="YYYY-MM-DD" + slotProps={{ textField: { size: "small", sx: { width: 200 } } }} + /> + </LocalizationProvider> + </Box> + <Button + variant="contained" + onClick={handleQuery} + disabled={loading} + size="small" + className="bg-blue-600 hover:bg-blue-700" + sx={{ minWidth: 80 }} + > + {loading ? "查询中..." : "查询"} + </Button> + </Box> + </Box> + <Box className="flex-1 overflow-auto"> + {schemes.length === 0 ? ( + <Box className="flex flex-col items-center justify-center h-full text-gray-400"> + <Box className="mb-4"> + <svg + width="80" + height="80" + viewBox="0 0 80 80" + fill="none" + className="opacity-40" + > + <rect + x="10" + y="20" + width="60" + height="45" + rx="2" + stroke="currentColor" + strokeWidth="2" + /> + <line + x1="10" + y1="30" + x2="70" + y2="30" + stroke="currentColor" + strokeWidth="2" + /> + </svg> + </Box> + <Typography variant="body2">总共 0 条</Typography> + <Typography variant="body2" className="mt-1"> + No data + </Typography> + </Box> + ) : ( + <Box className="space-y-2 p-2"> + <Typography variant="caption" className="text-gray-500 px-2"> + 共 {schemes.length} 条记录 + </Typography> + {schemes.map((scheme) => ( + <Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow"> + <CardContent className="p-3 pb-2 last:pb-3"> + <Box className="flex items-start justify-between gap-2 mb-2"> + <Box className="flex-1 min-w-0"> + <Box className="flex items-center gap-2 mb-1"> + <Typography variant="body2" className="font-medium truncate" title={scheme.scheme_name}> + {scheme.scheme_name} + </Typography> + <Chip size="small" variant="outlined" color="primary" label="DMA漏损" className="h-5" /> + </Box> + <Typography variant="caption" className="text-gray-500 block"> + ID: {scheme.scheme_id} · 日期: {dayjs(scheme.create_time).format("MM-DD")} + </Typography> + </Box> + <Box className="flex gap-1 ml-2"> + <Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}> + <IconButton + size="small" + onClick={() => setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)} + color="primary" + className="p-1" + > + <InfoIcon fontSize="small" /> + </IconButton> + </Tooltip> + </Box> + </Box> + <Collapse in={expandedId === scheme.scheme_id}> + <Box className="mt-2 pt-3 border-t border-gray-200"> + <Box className="mb-3 rounded-md bg-gray-50 px-3 py-2 space-y-2"> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 分区数量: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {String((scheme.scheme_detail as any)?.result_summary?.area_count ?? "-")} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 用户: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {scheme.username || "-"} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 最大漏损: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {(() => { + const value = Number((scheme.scheme_detail as any)?.result_summary?.max_leakage); + return Number.isFinite(value) ? `${value.toFixed(3)} m3/s` : "-"; + })()} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 总漏损流量: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {(() => { + const value = Number((scheme.scheme_detail as any)?.algorithm_params?.q_sum); + const unit = String((scheme.scheme_detail as any)?.algorithm_params?.q_sum_unit || "m3/s"); + return Number.isFinite(value) ? `${value.toFixed(3)} ${unit}` : "-"; + })()} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 方案时间: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {dayjs(scheme.scheme_start_time || scheme.create_time).format("YYYY-MM-DD HH:mm")} + </Typography> + </Box> + </Box> + <Box className="pt-2 border-t border-gray-100"> + <Button + variant="contained" + fullWidth + size="small" + className="bg-blue-600 hover:bg-blue-700" + sx={{ textTransform: "none", fontWeight: 500 }} + onClick={() => handleViewSchemeResult(scheme.scheme_name)} + > + 查看识别结果 + </Button> + </Box> + </Box> + </Collapse> + </CardContent> + </Card> + ))} + </Box> + )} + </Box> + </Box> + ); +}; + +export default SchemeQuery; diff --git a/src/components/olmap/DMALeakDetection/types.ts b/src/components/olmap/DMALeakDetection/types.ts new file mode 100644 index 0000000..b7bf649 --- /dev/null +++ b/src/components/olmap/DMALeakDetection/types.ts @@ -0,0 +1,53 @@ +export interface LeakageRow { + Area: string; + LeakageRatioRaw: number; + LeakageRatio: number; + LeakageFlow_m3_per_s: number; +} + +export interface LeakageSchemeRecord { + scheme_id: number; + scheme_name: string; + scheme_type: string; + username: string; + create_time: string; + scheme_start_time: string; + scheme_detail: Record<string, unknown>; +} + +export interface LeakageResultDetail { + scheme_name?: string; + network?: string; + sensor_nodes: string[]; + rows: LeakageRow[]; + node_area_map: Record<string, string>; + areas: Array<{ + area_id: string; + node_count: number; + node_ids: string[]; + sensor_nodes: string[]; + }>; + drawing_payload: { + type: "FeatureCollection"; + features: Array<Record<string, unknown>>; + }; + node_visual_payload?: { + type: "FeatureCollection"; + features: Array<Record<string, unknown>>; + }; + scheme_detail?: { + algorithm_params?: { + q_sum?: number; + q_sum_unit?: string; + [key: string]: unknown; + }; + result_summary?: { + area_count?: number; + max_leakage?: number; + }; + [key: string]: unknown; + }; + scheme_start_time?: string; + create_time?: string; + username?: string; +} -- 2.54.0 From 5430a9d88545adb49c60a0aaec78513f0f60359c Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 6 Mar 2026 10:15:47 +0800 Subject: [PATCH 030/281] =?UTF-8?q?=E5=88=86=E7=A6=BB=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=A0=87=E7=AD=BE=E9=A1=B5=EF=BC=9B=E9=99=90?= =?UTF-8?q?=E5=88=B6=20DMA=20=E6=95=B0=E9=87=8F=E6=9C=80=E5=A4=A7=E6=95=B0?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DMALeakDetection/AnalysisParameters.tsx | 12 +- .../DMALeakDetectionPanel.tsx | 180 +----------- .../DMALeakDetection/RecognitionResults.tsx | 258 ++++++++++++++++++ .../olmap/DMALeakDetection/utils.ts | 21 ++ 4 files changed, 292 insertions(+), 179 deletions(-) create mode 100644 src/components/olmap/DMALeakDetection/RecognitionResults.tsx create mode 100644 src/components/olmap/DMALeakDetection/utils.ts diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index a19bd44..6489ec5 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -120,11 +120,19 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={dmaCount} onChange={(e) => { const value = Number.parseInt(e.target.value, 10); - setDmaCount(Number.isNaN(value) ? 5 : Math.max(3, value)); + // Limit between 3 and 10 + if (Number.isNaN(value)) { + setDmaCount(5); + } else if (value > 10) { + setDmaCount(10); + } else { + setDmaCount(Math.max(3, value)); + } }} fullWidth size="small" - inputProps={{ min: 3, step: 1 }} + inputProps={{ min: 3, max: 10, step: 1 }} + helperText="DMA 数量限制为 3-10 个" /> </Box> diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index 6c1e3de..575d649 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -9,12 +9,6 @@ import { Typography, IconButton, Tooltip, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Chip, } from "@mui/material"; import { Analytics as AnalyticsIcon, @@ -23,7 +17,6 @@ import { ChevronRight, FormatListBulleted, } from "@mui/icons-material"; -import dayjs from "dayjs"; import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; @@ -33,6 +26,8 @@ import { useMap } from "@app/OlMap/MapComponent"; import StyleLegend from "@app/OlMap/Controls/StyleLegend"; import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; +import RecognitionResults from "./RecognitionResults"; +import { getAreaColor } from "./utils"; import { LeakageResultDetail } from "./types"; const TabPanel = ({ @@ -49,27 +44,7 @@ const TabPanel = ({ </div> ); -const AREA_COLORS = [ - "#2563eb", - "#7c3aed", - "#0891b2", - "#16a34a", - "#ca8a04", - "#dc2626", - "#ea580c", - "#0f766e", - "#4338ca", - "#be123c", -]; -const getAreaColor = (areaId: string | number | undefined) => { - const text = String(areaId ?? ""); - let hash = 0; - for (let i = 0; i < text.length; i += 1) { - hash = (hash * 31 + text.charCodeAt(i)) >>> 0; - } - return AREA_COLORS[hash % AREA_COLORS.length]; -}; const DMALeakDetectionPanel: React.FC = () => { const map = useMap(); @@ -79,12 +54,6 @@ const DMALeakDetectionPanel: React.FC = () => { const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null); const [nodeLayer, setNodeLayer] = useState<VectorLayer<VectorSource> | null>(null); - const sortedRows = useMemo(() => { - if (!result?.rows) return []; - return [...result.rows].sort( - (a, b) => b.LeakageFlow_m3_per_s - a.LeakageFlow_m3_per_s, - ); - }, [result]); const drawerWidth = 450; const panelTitle = "DMA漏损识别"; const activeAreas = loadedResult?.areas ?? []; @@ -262,150 +231,7 @@ const DMALeakDetectionPanel: React.FC = () => { <SchemeQuery onViewResult={handleViewResult} /> </TabPanel> <TabPanel value={tab} index={2}> - {!result || !sortedRows.length ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4"> - <Box className="mb-4"> - <svg width="80" height="80" viewBox="0 0 80 80" fill="none" className="opacity-40"> - <rect x="10" y="20" width="60" height="45" rx="2" stroke="currentColor" strokeWidth="2" /> - <line x1="10" y1="30" x2="70" y2="30" stroke="currentColor" strokeWidth="2" /> - </svg> - </Box> - <Typography variant="body2">暂无识别结果</Typography> - <Typography variant="body2" className="mt-1"> - 请先加载方案或执行识别分析 - </Typography> - </Box> - ) : ( - <Box className="h-full overflow-auto p-1"> - {/* 方案详情卡片 */} - <Box className="mb-4 space-y-3"> - <Box className="flex items-center justify-between px-1"> - <Box className="flex items-center gap-2"> - <Box className="w-1 h-4 bg-blue-600 rounded-full" /> - <Typography - variant="h6" - className="font-bold text-gray-900 truncate" - sx={{ fontSize: "1.1rem" }} - title={result.scheme_name || ""} - > - {result.scheme_name || "漏损识别结果"} - </Typography> - </Box> - {result.username && ( - <Chip - label={result.username} - size="small" - sx={{ - height: 24, - backgroundColor: "#f3f4f6", - color: "#4b5563", - border: "none", - fontWeight: 500 - }} - /> - )} - </Box> - - <Box className="grid grid-cols-2 gap-3"> - {/* 方案时间 */} - <Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm"> - <Typography variant="caption" className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> - 方案时间 - </Typography> - <Typography variant="body2" className="font-bold text-blue-900"> - {dayjs(result.scheme_start_time || result.create_time).format("MM-DD HH:mm")} - </Typography> - </Box> - - {/* 总漏损流量 */} - <Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm"> - <Typography variant="caption" className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> - 总漏损流量 - </Typography> - <Typography variant="body2" className="font-bold text-orange-900"> - {(() => { - const val = (result.scheme_detail as any)?.algorithm_params?.q_sum; - const unit = (result.scheme_detail as any)?.algorithm_params?.q_sum_unit || "m3/s"; - return val !== undefined ? `${Number(val).toFixed(3)} ${unit}` : "-"; - })()} - </Typography> - </Box> - - {/* 分区数量 */} - <Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm"> - <Typography variant="caption" className="text-green-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> - 分区数量 - </Typography> - <Typography variant="body2" className="font-bold text-green-900"> - {(result.scheme_detail as any)?.result_summary?.area_count ?? result.areas?.length ?? 0} 个 - </Typography> - </Box> - - {/* 最大漏损 */} - <Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm"> - <Typography variant="caption" className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> - 最大漏损 - </Typography> - <Typography variant="body2" className="font-bold text-purple-900"> - {(() => { - const maxL = (result.scheme_detail as any)?.result_summary?.max_leakage; - return maxL !== undefined ? `${Number(maxL).toFixed(3)} m3/s` : "-"; - })()} - </Typography> - </Box> - </Box> - </Box> - - {/* 漏损列表 */} - <Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden"> - <Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white"> - <Box className="flex items-center gap-2"> - <FormatListBulleted className="text-blue-600 w-5 h-5" /> - <Typography variant="subtitle1" className="font-bold text-gray-800"> - 区域漏损列表 - </Typography> - </Box> - <Chip - size="small" - label={`${sortedRows.length} 条`} - sx={{ - height: 22, - backgroundColor: "rgba(37, 99, 235, 0.08)", - color: "#2563eb", - fontWeight: 600, - fontSize: "0.75rem", - border: "none" - }} - /> - </Box> - <Table size="small"> - <TableHead> - <TableRow sx={{ backgroundColor: "#f8fafc" }}> - <TableCell sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pl: 3 }}>区域</TableCell> - <TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }}>漏损量占比 (%)</TableCell> - <TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }}>漏损量 (m3/s)</TableCell> - </TableRow> - </TableHead> - <TableBody> - {sortedRows.map((row) => ( - <TableRow key={row.Area} hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}> - <TableCell sx={{ pl: 3, py: 1.2 }}> - <Box className="flex items-center gap-2"> - <Box className="w-2 h-2 rounded-full" sx={{ backgroundColor: getAreaColor(row.Area) }} /> - <Typography variant="body2" className="font-medium text-gray-700"> - {row.Area} - </Typography> - </Box> - </TableCell> - <TableCell align="right" sx={{ py: 1.2, color: "#475569" }}>{(row.LeakageRatio * 100).toFixed(3)}</TableCell> - <TableCell align="right" sx={{ pr: 3, py: 1.2, fontWeight: 500, color: "#334155" }}>{row.LeakageFlow_m3_per_s.toFixed(3)}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </Box> - </Box> - )} + <RecognitionResults result={result} /> </TabPanel> </Box> </Drawer> diff --git a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx new file mode 100644 index 0000000..ac37d11 --- /dev/null +++ b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx @@ -0,0 +1,258 @@ +"use client"; + +import React, { useMemo } from "react"; +import { + Box, + Typography, + Chip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@mui/material"; +import { FormatListBulleted } from "@mui/icons-material"; +import dayjs from "dayjs"; +import { getAreaColor } from "./utils"; +import { LeakageResultDetail } from "./types"; + +interface Props { + result: LeakageResultDetail | null; +} + +const RecognitionResults: React.FC<Props> = ({ result }) => { + const sortedRows = useMemo(() => { + if (!result?.rows) return []; + return [...result.rows].sort( + (a, b) => b.LeakageFlow_m3_per_s - a.LeakageFlow_m3_per_s, + ); + }, [result]); + + if (!result || !sortedRows.length) { + return ( + <Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4"> + <Box className="mb-4"> + <svg + width="80" + height="80" + viewBox="0 0 80 80" + fill="none" + className="opacity-40" + > + <rect + x="10" + y="20" + width="60" + height="45" + rx="2" + stroke="currentColor" + strokeWidth="2" + /> + <line + x1="10" + y1="30" + x2="70" + y2="30" + stroke="currentColor" + strokeWidth="2" + /> + </svg> + </Box> + <Typography variant="body2">暂无识别结果</Typography> + <Typography variant="body2" className="mt-1"> + 请先加载方案或执行识别分析 + </Typography> + </Box> + ); + } + + return ( + <Box className="h-full overflow-auto p-1"> + {/* 方案详情卡片 */} + <Box className="mb-4 space-y-3"> + <Box className="flex items-center justify-between px-1"> + <Box className="flex items-center gap-2"> + <Box className="w-1 h-4 bg-blue-600 rounded-full" /> + <Typography + variant="h6" + className="font-bold text-gray-900 truncate" + sx={{ fontSize: "1.1rem" }} + title={result.scheme_name || ""} + > + {result.scheme_name || "漏损识别结果"} + </Typography> + </Box> + {result.username && ( + <Chip + label={result.username} + size="small" + sx={{ + height: 24, + backgroundColor: "#f3f4f6", + color: "#4b5563", + border: "none", + fontWeight: 500, + }} + /> + )} + </Box> + + <Box className="grid grid-cols-2 gap-3"> + {/* 方案时间 */} + <Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm"> + <Typography + variant="caption" + className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide" + > + 方案时间 + </Typography> + <Typography variant="body2" className="font-bold text-blue-900"> + {dayjs(result.scheme_start_time || result.create_time).format( + "MM-DD HH:mm", + )} + </Typography> + </Box> + + {/* 总漏损流量 */} + <Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm"> + <Typography + variant="caption" + className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide" + > + 总漏损流量 + </Typography> + <Typography variant="body2" className="font-bold text-orange-900"> + {(() => { + const val = (result.scheme_detail as any)?.algorithm_params + ?.q_sum; + const unit = + (result.scheme_detail as any)?.algorithm_params?.q_sum_unit || + "m3/s"; + return val !== undefined + ? `${Number(val).toFixed(3)} ${unit}` + : "-"; + })()} + </Typography> + </Box> + + {/* 分区数量 */} + <Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm"> + <Typography + variant="caption" + className="text-green-700 font-semibold block mb-1 text-xs uppercase tracking-wide" + > + 分区数量 + </Typography> + <Typography variant="body2" className="font-bold text-green-900"> + {(result.scheme_detail as any)?.result_summary?.area_count ?? + result.areas?.length ?? + 0}{" "} + 个 + </Typography> + </Box> + + {/* 最大漏损 */} + <Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm"> + <Typography + variant="caption" + className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide" + > + 最大漏损 + </Typography> + <Typography variant="body2" className="font-bold text-purple-900"> + {(() => { + const maxL = (result.scheme_detail as any)?.result_summary + ?.max_leakage; + return maxL !== undefined + ? `${Number(maxL).toFixed(3)} m3/s` + : "-"; + })()} + </Typography> + </Box> + </Box> + </Box> + + {/* 漏损列表 */} + <Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden"> + <Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white"> + <Box className="flex items-center gap-2"> + <FormatListBulleted className="text-blue-600 w-5 h-5" /> + <Typography variant="subtitle1" className="font-bold text-gray-800"> + 区域漏损列表 + </Typography> + </Box> + <Chip + size="small" + label={`${sortedRows.length} 条`} + sx={{ + height: 22, + backgroundColor: "rgba(37, 99, 235, 0.08)", + color: "#2563eb", + fontWeight: 600, + fontSize: "0.75rem", + border: "none", + }} + /> + </Box> + <Table size="small"> + <TableHead> + <TableRow sx={{ backgroundColor: "#f8fafc" }}> + <TableCell + sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pl: 3 }} + > + 区域 + </TableCell> + <TableCell + align="right" + sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }} + > + 漏损量占比 (%) + </TableCell> + <TableCell + align="right" + sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }} + > + 漏损量 (m3/s) + </TableCell> + </TableRow> + </TableHead> + <TableBody> + {sortedRows.map((row) => ( + <TableRow + key={row.Area} + hover + sx={{ "&:last-child td, &:last-child th": { border: 0 } }} + > + <TableCell sx={{ pl: 3, py: 1.2 }}> + <Box className="flex items-center gap-2"> + <Box + className="w-2 h-2 rounded-full" + sx={{ backgroundColor: getAreaColor(row.Area) }} + /> + <Typography + variant="body2" + className="font-medium text-gray-700" + > + {row.Area} + </Typography> + </Box> + </TableCell> + <TableCell align="right" sx={{ py: 1.2, color: "#475569" }}> + {(row.LeakageRatio * 100).toFixed(3)} + </TableCell> + <TableCell + align="right" + sx={{ pr: 3, py: 1.2, fontWeight: 500, color: "#334155" }} + > + {row.LeakageFlow_m3_per_s.toFixed(3)} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </Box> + </Box> + ); +}; + +export default RecognitionResults; diff --git a/src/components/olmap/DMALeakDetection/utils.ts b/src/components/olmap/DMALeakDetection/utils.ts new file mode 100644 index 0000000..bd46411 --- /dev/null +++ b/src/components/olmap/DMALeakDetection/utils.ts @@ -0,0 +1,21 @@ +export const AREA_COLORS = [ + "#2563eb", + "#7c3aed", + "#0891b2", + "#16a34a", + "#ca8a04", + "#dc2626", + "#ea580c", + "#0f766e", + "#4338ca", + "#be123c", +]; + +export const getAreaColor = (areaId: string | number | undefined) => { + const text = String(areaId ?? ""); + let hash = 0; + for (let i = 0; i < text.length; i += 1) { + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + } + return AREA_COLORS[hash % AREA_COLORS.length]; +}; -- 2.54.0 From bf6edf26621d36f403bfb69749598afcdbe35e72 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 6 Mar 2026 14:09:26 +0800 Subject: [PATCH 031/281] =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DMALeakDetectionPanel.tsx | 161 ++++++++++++------ 1 file changed, 105 insertions(+), 56 deletions(-) diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index 575d649..75cb5a0 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -17,11 +17,10 @@ import { ChevronRight, FormatListBulleted, } from "@mui/icons-material"; -import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style"; -import VectorLayer from "ol/layer/Vector"; -import VectorSource from "ol/source/Vector"; -import Feature from "ol/Feature"; -import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import VectorTileSource from "ol/source/VectorTile"; +import { VectorTile } from "ol"; +import { FlatStyleLike } from "ol/style/flat"; import { useMap } from "@app/OlMap/MapComponent"; import StyleLegend from "@app/OlMap/Controls/StyleLegend"; import AnalysisParameters from "./AnalysisParameters"; @@ -29,6 +28,7 @@ import SchemeQuery from "./SchemeQuery"; import RecognitionResults from "./RecognitionResults"; import { getAreaColor } from "./utils"; import { LeakageResultDetail } from "./types"; +import { config } from "@/config/config"; const TabPanel = ({ value, @@ -44,7 +44,7 @@ const TabPanel = ({ </div> ); - +const DMA_AREA_INDEX_PROPERTY = "dma_area_index"; const DMALeakDetectionPanel: React.FC = () => { const map = useMap(); @@ -52,7 +52,6 @@ const DMALeakDetectionPanel: React.FC = () => { const [tab, setTab] = useState(0); const [result, setResult] = useState<LeakageResultDetail | null>(null); const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null); - const [nodeLayer, setNodeLayer] = useState<VectorLayer<VectorSource> | null>(null); const drawerWidth = 450; const panelTitle = "DMA漏损识别"; @@ -70,55 +69,6 @@ const DMALeakDetectionPanel: React.FC = () => { [activeAreas.length], ); - useEffect(() => { - if (!map) return; - const layer = new VectorLayer({ - source: new VectorSource(), - maxZoom: 24, - minZoom: 12, - properties: { - name: "DMA漏损节点着色", - value: "dma_leak_nodes", - }, - style: (feature) => { - const areaId = feature.get("__areaId"); - return new Style({ - image: new CircleStyle({ - radius: 4.5, - fill: new Fill({ color: getAreaColor(areaId) }), - stroke: new Stroke({ color: "#ffffff", width: 1.2 }), - }), - }); - }, - }); - map.addLayer(layer); - setNodeLayer(layer); - return () => { - map.removeLayer(layer); - }; - }, [map]); - - useEffect(() => { - if (!nodeLayer) return; - const source = nodeLayer.getSource(); - if (!source) return; - source.clear(); - if (!loadedResult) return; - - const nodeAreaMap = loadedResult.node_area_map || {}; - const nodeIds = Object.keys(nodeAreaMap); - if (nodeIds.length === 0) return; - - queryFeaturesByIds(nodeIds, "geo_junctions_mat").then((features) => { - if (!features?.length) return; - features.forEach((feature) => { - const nodeId = String(feature.get("id") ?? ""); - feature.set("__areaId", nodeAreaMap[nodeId] ?? ""); - }); - source.addFeatures(features as Feature[]); - }); - }, [loadedResult, nodeLayer]); - const handleAnalysisResult = useCallback((res: LeakageResultDetail) => { setResult(res); }, []); @@ -129,6 +79,105 @@ const DMALeakDetectionPanel: React.FC = () => { setTab(2); }, []); + useEffect(() => { + if (!map) return; + const junctionLayer = map + .getAllLayers() + .find( + (layer) => + layer instanceof WebGLVectorTileLayer && layer.get("value") === "junctions", + ) as WebGLVectorTileLayer | undefined; + if (!junctionLayer) return; + const source = junctionLayer.getSource() as VectorTileSource; + if (!source) return; + + if (!loadedResult || !loadedResult.node_area_map) { + junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); + return; + } + + const fallbackAreaIds = Array.from( + new Set(Object.values(loadedResult.node_area_map || {}).map(String)), + ); + const areaIds = (loadedResult.areas || []).length + ? loadedResult.areas.map((area) => String(area.area_id)) + : fallbackAreaIds; + if (areaIds.length === 0) { + junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); + return; + } + + const areaIdToIndex = new Map<string, number>(); + areaIds.forEach((areaId, index) => { + areaIdToIndex.set(areaId, index + 1); + }); + + const nodeAreaIndexMap = new Map<string, number>(); + Object.entries(loadedResult.node_area_map || {}).forEach(([nodeId, areaId]) => { + const idx = areaIdToIndex.get(String(areaId)); + if (idx !== undefined) { + nodeAreaIndexMap.set(String(nodeId), idx); + } + }); + + const applyFeatureAreaIndex = (renderFeature: any) => { + const featureId = String(renderFeature.get("id") ?? ""); + const areaIndex = nodeAreaIndexMap.get(featureId); + if (areaIndex !== undefined) { + renderFeature.properties_[DMA_AREA_INDEX_PROPERTY] = areaIndex; + } + }; + + const sourceTiles = (source as any).sourceTiles_; + if (sourceTiles) { + Object.values(sourceTiles).forEach((vectorTile: any) => { + const renderFeatures = vectorTile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + applyFeatureAreaIndex(renderFeature); + }); + }); + } + + const listener = (event: any) => { + try { + if (event.tile instanceof VectorTile) { + const renderFeatures = event.tile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + applyFeatureAreaIndex(renderFeature); + }); + } + } catch (error) { + console.error("Error applying DMA area mapping:", error); + } + }; + source.on("tileloadend", listener); + + const fillCases: any[] = []; + areaIds.forEach((areaId, index) => { + fillCases.push( + ["==", ["get", DMA_AREA_INDEX_PROPERTY], index + 1], + getAreaColor(areaId), + ); + }); + const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]); + const defaultStrokeColor = String( + config.MAP_DEFAULT_STYLE["circle-stroke-color"], + ); + const dmaStyle: FlatStyleLike = { + ...config.MAP_DEFAULT_STYLE, + "circle-fill-color": ["case", ...fillCases, defaultFillColor], + "circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], + }; + junctionLayer.setStyle(dmaStyle); + + return () => { + source.un("tileloadend", listener); + junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); + }; + }, [map, loadedResult]); + return ( <> {!open && ( -- 2.54.0 From 9beba1cf6f83e9c186d249e0fa9136365cee93ff Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 6 Mar 2026 14:13:50 +0800 Subject: [PATCH 032/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=81=97=E4=BC=A0?= =?UTF-8?q?=E7=AE=97=E6=B3=95=E9=BB=98=E8=AE=A4=E5=8F=82=E6=95=B0=EF=BC=9B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=BC=8F=E6=8D=9F=E6=B5=81=E9=87=8F=E5=8D=95?= =?UTF-8?q?=E4=BD=8D=E4=B8=BAm3/h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DMALeakDetection/AnalysisParameters.tsx | 57 ++++++++++--------- .../DMALeakDetection/RecognitionResults.tsx | 21 ++++--- .../olmap/DMALeakDetection/SchemeQuery.tsx | 15 ++++- .../olmap/DMALeakDetection/utils.ts | 17 ++++++ 4 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 6489ec5..1c45f16 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -20,6 +20,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { LeakageResultDetail } from "./types"; +import { DMA_FLOW_DISPLAY_UNIT, toM3s } from "./utils"; interface Props { onResult: (result: LeakageResultDetail) => void; @@ -33,15 +34,15 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { dayjs().subtract(2, "hour"), ); const [endTime, setEndTime] = useState<Dayjs | null>(dayjs()); - const [popSize, setPopSize] = useState<number>(50); - const [maxGen, setMaxGen] = useState<number>(100); - const [qSum, setQSum] = useState<number>(0.4); + const [popSize, setPopSize] = useState<number>(10); + const [maxGen, setMaxGen] = useState<number>(50); + const [qSum, setQSum] = useState<number>(1440); const [advancedOpen, setAdvancedOpen] = useState(false); const [running, setRunning] = useState(false); const isValid = useMemo(() => { if (!schemeName.trim() || !startTime || !endTime) return false; - return startTime.isBefore(endTime) && qSum >= 0.1; + return startTime.isBefore(endTime) && qSum >= 360; }, [schemeName, startTime, endTime, qSum]); const handleRun = async () => { @@ -67,9 +68,9 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { scada_end: endTime.toISOString(), pop_size: popSize, max_gen: maxGen, - q_sum: qSum, + q_sum: toM3s(qSum, DMA_FLOW_DISPLAY_UNIT), q_sum_unit: "m3/s", - output_flow_unit: "m3/s", + output_flow_unit: DMA_FLOW_DISPLAY_UNIT, }, ); onResult(response.data as LeakageResultDetail); @@ -115,25 +116,25 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <Typography variant="subtitle2" className="mb-1 font-medium"> DMA 数量 </Typography> - <TextField - type="number" - value={dmaCount} - onChange={(e) => { - const value = Number.parseInt(e.target.value, 10); - // Limit between 3 and 10 - if (Number.isNaN(value)) { - setDmaCount(5); - } else if (value > 10) { - setDmaCount(10); - } else { - setDmaCount(Math.max(3, value)); - } - }} - fullWidth - size="small" - inputProps={{ min: 3, max: 10, step: 1 }} - helperText="DMA 数量限制为 3-10 个" - /> + <TextField + type="number" + value={dmaCount} + onChange={(e) => { + const value = Number.parseInt(e.target.value, 10); + // Limit between 3 and 10 + if (Number.isNaN(value)) { + setDmaCount(5); + } else if (value > 10) { + setDmaCount(10); + } else { + setDmaCount(Math.max(3, value)); + } + }} + fullWidth + size="small" + inputProps={{ min: 3, max: 10, step: 1 }} + helperText="DMA 数量限制为 3-10 个" + /> </Box> <LocalizationProvider @@ -171,7 +172,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <Box className="flex flex-col gap-2"> <Typography variant="subtitle2" className="mb-1 font-medium"> - 总漏损流量 (m3/s) + 总漏损流量 ({DMA_FLOW_DISPLAY_UNIT}) </Typography> <TextField type="number" @@ -179,9 +180,9 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={qSum} onChange={(e) => { const value = Number(e.target.value); - setQSum(Number.isNaN(value) ? 0.4 : Math.max(0.1, value)); + setQSum(Number.isNaN(value) ? 1440 : Math.max(360, value)); }} - inputProps={{ min: 0.1, step: 0.1 }} + inputProps={{ min: 360, step: 10 }} /> <Box sx={{ diff --git a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx index ac37d11..601e6f1 100644 --- a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx +++ b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx @@ -13,7 +13,7 @@ import { } from "@mui/material"; import { FormatListBulleted } from "@mui/icons-material"; import dayjs from "dayjs"; -import { getAreaColor } from "./utils"; +import { DMA_FLOW_DISPLAY_UNIT, getAreaColor, toM3h } from "./utils"; import { LeakageResultDetail } from "./types"; interface Props { @@ -125,11 +125,13 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { {(() => { const val = (result.scheme_detail as any)?.algorithm_params ?.q_sum; - const unit = + const unit = String( (result.scheme_detail as any)?.algorithm_params?.q_sum_unit || - "m3/s"; - return val !== undefined - ? `${Number(val).toFixed(3)} ${unit}` + "m3/s", + ); + const qSumM3h = toM3h(Number(val), unit); + return Number.isFinite(qSumM3h) + ? `${qSumM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` : "-"; })()} </Typography> @@ -163,8 +165,9 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { {(() => { const maxL = (result.scheme_detail as any)?.result_summary ?.max_leakage; - return maxL !== undefined - ? `${Number(maxL).toFixed(3)} m3/s` + const maxLeakageM3h = toM3h(Number(maxL), "m3/s"); + return Number.isFinite(maxLeakageM3h) + ? `${maxLeakageM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` : "-"; })()} </Typography> @@ -212,7 +215,7 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }} > - 漏损量 (m3/s) + 漏损量 ({DMA_FLOW_DISPLAY_UNIT}) </TableCell> </TableRow> </TableHead> @@ -244,7 +247,7 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { align="right" sx={{ pr: 3, py: 1.2, fontWeight: 500, color: "#334155" }} > - {row.LeakageFlow_m3_per_s.toFixed(3)} + {toM3h(Number(row.LeakageFlow_m3_per_s), "m3/s").toFixed(3)} </TableCell> </TableRow> ))} diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index e7c6f91..70c403e 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; +import { DMA_FLOW_DISPLAY_UNIT, toM3h } from "./utils"; interface Props { onViewResult: (result: LeakageResultDetail) => void; @@ -207,7 +208,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { <Typography variant="caption" className="font-medium text-gray-900"> {(() => { const value = Number((scheme.scheme_detail as any)?.result_summary?.max_leakage); - return Number.isFinite(value) ? `${value.toFixed(3)} m3/s` : "-"; + const maxLeakageM3h = toM3h(value, "m3/s"); + return Number.isFinite(maxLeakageM3h) + ? `${maxLeakageM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` + : "-"; })()} </Typography> </Box> @@ -218,8 +222,13 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { <Typography variant="caption" className="font-medium text-gray-900"> {(() => { const value = Number((scheme.scheme_detail as any)?.algorithm_params?.q_sum); - const unit = String((scheme.scheme_detail as any)?.algorithm_params?.q_sum_unit || "m3/s"); - return Number.isFinite(value) ? `${value.toFixed(3)} ${unit}` : "-"; + const unit = String( + (scheme.scheme_detail as any)?.algorithm_params?.q_sum_unit || "m3/s", + ); + const qSumM3h = toM3h(value, unit); + return Number.isFinite(qSumM3h) + ? `${qSumM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` + : "-"; })()} </Typography> </Box> diff --git a/src/components/olmap/DMALeakDetection/utils.ts b/src/components/olmap/DMALeakDetection/utils.ts index bd46411..c38ee6f 100644 --- a/src/components/olmap/DMALeakDetection/utils.ts +++ b/src/components/olmap/DMALeakDetection/utils.ts @@ -11,6 +11,9 @@ export const AREA_COLORS = [ "#be123c", ]; +export const DMA_FLOW_DISPLAY_UNIT = "m3/h"; +const M3H_FACTOR = 3600; + export const getAreaColor = (areaId: string | number | undefined) => { const text = String(areaId ?? ""); let hash = 0; @@ -19,3 +22,17 @@ export const getAreaColor = (areaId: string | number | undefined) => { } return AREA_COLORS[hash % AREA_COLORS.length]; }; + +export const toM3h = (value: number, sourceUnit: string = "m3/s") => { + if (!Number.isFinite(value)) return Number.NaN; + const normalizedUnit = sourceUnit.trim().toLowerCase(); + if (normalizedUnit === "m3/h") return value; + return value * M3H_FACTOR; +}; + +export const toM3s = (value: number, sourceUnit: string = "m3/h") => { + if (!Number.isFinite(value)) return Number.NaN; + const normalizedUnit = sourceUnit.trim().toLowerCase(); + if (normalizedUnit === "m3/s") return value; + return value / M3H_FACTOR; +}; -- 2.54.0 From 5ed6740a24a6199491f2219c3482cfc5e723871f Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 10:50:07 +0800 Subject: [PATCH 033/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=88=86=E7=AE=A1?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../burst-location/loading.tsx | 5 + .../burst-location/page.tsx | 20 + src/app/_refine_context.tsx | 27 +- .../BurstLocation/AnalysisParameters.tsx | 486 ++++++++++++++++++ .../BurstLocation/BurstLocationPanel.tsx | 162 ++++++ .../olmap/BurstLocation/LocationResults.tsx | 267 ++++++++++ .../olmap/BurstLocation/SchemeQuery.tsx | 253 +++++++++ src/components/olmap/BurstLocation/types.ts | 27 + .../DMALeakDetection/AnalysisParameters.tsx | 2 +- .../DMALeakDetectionPanel.tsx | 2 +- .../olmap/DMALeakDetection/utils.ts | 10 +- 11 files changed, 1247 insertions(+), 14 deletions(-) create mode 100644 src/app/(main)/hydraulic-simulation/burst-location/loading.tsx create mode 100644 src/app/(main)/hydraulic-simulation/burst-location/page.tsx create mode 100644 src/components/olmap/BurstLocation/AnalysisParameters.tsx create mode 100644 src/components/olmap/BurstLocation/BurstLocationPanel.tsx create mode 100644 src/components/olmap/BurstLocation/LocationResults.tsx create mode 100644 src/components/olmap/BurstLocation/SchemeQuery.tsx create mode 100644 src/components/olmap/BurstLocation/types.ts diff --git a/src/app/(main)/hydraulic-simulation/burst-location/loading.tsx b/src/app/(main)/hydraulic-simulation/burst-location/loading.tsx new file mode 100644 index 0000000..2c57921 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/burst-location/loading.tsx @@ -0,0 +1,5 @@ +import { MapSkeleton } from "@components/loading/MapSkeleton"; + +export default function Loading() { + return <MapSkeleton />; +} diff --git a/src/app/(main)/hydraulic-simulation/burst-location/page.tsx b/src/app/(main)/hydraulic-simulation/burst-location/page.tsx new file mode 100644 index 0000000..f8e01cd --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/burst-location/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import MapComponent from "@app/OlMap/MapComponent"; +import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel"; + +export default function Home() { + return ( + <div className="relative w-full h-full overflow-hidden"> + <MapComponent> + <MapToolbar + queryType="scheme" + schemeType="burst_location" + hiddenButtons={["style"]} + /> + <BurstLocationPanel /> + </MapComponent> + </div> + ); +} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index 282ea2a..4d75a3b 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -18,12 +18,16 @@ import { ProjectProvider } from "@/contexts/ProjectContext"; import { useAuthStore } from "@/store/authStore"; import { LiaNetworkWiredSolid } from "react-icons/lia"; -import { TbDatabaseEdit } from "react-icons/tb"; +import { TbDatabaseEdit, TbLocationPin } from "react-icons/tb"; import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; -import { TbLocationPin } from "react-icons/tb"; import { AiOutlinePartition } from "react-icons/ai"; import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; +import { + Analytics as AnalyticsIcon, + MyLocation as MyLocationIcon, + Search as SearchIcon, +} from "@mui/icons-material"; type RefineContextProps = { defaultMode?: string; @@ -172,21 +176,30 @@ const App = (props: React.PropsWithChildren<AppProps>) => { }, }, { - name: "爆管分析定位", + name: "爆管模拟", list: "/hydraulic-simulation/pipe-burst-analysis", meta: { parent: "Hydraulic Simulation", icon: <TbLocationPin className="w-6 h-6" />, - label: "爆管分析定位", + label: "爆管模拟", }, }, { - name: "DMA漏损识别", + name: "爆管定位", + list: "/hydraulic-simulation/burst-location", + meta: { + parent: "Hydraulic Simulation", + icon: <MyLocationIcon className="w-6 h-6" />, + label: "爆管定位", + }, + }, + { + name: "DMA 漏损识别", list: "/hydraulic-simulation/dma-leak-detection", meta: { parent: "Hydraulic Simulation", - icon: <TbLocationPin className="w-6 h-6" />, - label: "DMA漏损识别", + icon: <SearchIcon className="w-6 h-6" />, + label: "DMA 漏损识别", }, }, { diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx new file mode 100644 index 0000000..7b47ddc --- /dev/null +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -0,0 +1,486 @@ +"use client"; + +import React, { useCallback, useMemo, useState } from "react"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + Alert, + Box, + Button, + CircularProgress, + Collapse, + FormControl, + MenuItem, + Select, + TextField, + Typography, + IconButton, +} from "@mui/material"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import { useNotification } from "@refinedev/core"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +import { api } from "@/lib/api"; +import { NETWORK_NAME, config } from "@config/config"; +import { DMA_FLOW_DISPLAY_UNIT, toM3s } from "../DMALeakDetection/utils"; +import { BurstLocationResult } from "./types"; + +interface Props { + onResult: (result: BurstLocationResult) => void; +} + +interface SchemeItem { + scheme_id: number; + scheme_name: string; + scheme_type: string; + create_time: string; + scheme_start_time: string; + scheme_detail?: { + modify_total_duration: number; + }; +} + +type DataSource = "monitoring" | "simulation"; + +const AnalysisParameters: React.FC<Props> = ({ onResult }) => { + const { open } = useNotification(); + const [schemeName, setSchemeName] = useState(`Burst_Locate_${Date.now()}`); + const [dataSource, setDataSource] = useState<DataSource>("monitoring"); + const [schemes, setSchemes] = useState<SchemeItem[]>([]); + const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">(""); + const [schemeLoading, setSchemeLoading] = useState(false); + const [burstLeakage, setBurstLeakage] = useState<number>(1440); + const [enableFlow, setEnableFlow] = useState(false); + const [burstStartTime, setBurstStartTime] = useState<Dayjs | null>( + dayjs().subtract(20, "minute"), + ); + const [burstEndTime, setBurstEndTime] = useState<Dayjs | null>( + dayjs().subtract(5, "minute"), + ); + const [normalStartTime, setNormalStartTime] = useState<Dayjs | null>( + dayjs().subtract(2, "hour"), + ); + const [normalEndTime, setNormalEndTime] = useState<Dayjs | null>( + dayjs().subtract(90, "minute"), + ); + const [minDpressure, setMinDpressure] = useState<number>(2); + const [basicPressure, setBasicPressure] = useState<number>(10); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [running, setRunning] = useState(false); + + const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { + const start = dayjs(scheme.scheme_start_time); + const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; + const end = start.add(durationSeconds, "second"); + + setBurstStartTime(start); + setBurstEndTime(end); + setNormalStartTime(start.subtract(2, "hour")); + setNormalEndTime(start.subtract(10, "minute")); + }, []); + + const fetchSchemes = useCallback( + async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { + if (schemeLoading || (!force && schemes.length > 0)) return; + + setSchemeLoading(true); + try { + const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, { + params: { network: NETWORK_NAME }, + }); + const burstSchemes = (response.data as SchemeItem[]).filter( + (scheme) => scheme.scheme_type === "burst_analysis", + ); + + setSchemes(burstSchemes); + + if (selectedSchemeId) { + const matchedScheme = burstSchemes.find( + (scheme) => scheme.scheme_id === selectedSchemeId, + ); + if (matchedScheme) { + applySchemeTimeRange(matchedScheme); + } else { + setSelectedSchemeId(""); + } + } + + if (notify) { + open?.({ + type: "success", + message: "方案列表已刷新", + description: `当前可选爆管分析方案 ${burstSchemes.length} 个`, + }); + } + } catch (error: any) { + open?.({ + type: "error", + message: "刷新方案失败", + description: + error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表", + }); + } finally { + setSchemeLoading(false); + } + }, + [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId], + ); + + const handleDataSourceChange = (value: DataSource) => { + setDataSource(value); + if (value === "simulation") { + void fetchSchemes(); + } + }; + + const handleSchemeSelect = (schemeId: number) => { + setSelectedSchemeId(schemeId); + const scheme = schemes.find((item) => item.scheme_id === schemeId); + if (scheme) { + applySchemeTimeRange(scheme); + } + }; + + const isValid = useMemo(() => { + if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false; + if (!burstStartTime || !burstEndTime || !normalStartTime || !normalEndTime) { + return false; + } + if (dataSource === "simulation" && !selectedSchemeId) { + return false; + } + + return ( + burstStartTime.isBefore(burstEndTime) && + normalStartTime.isBefore(normalEndTime) + ); + }, [ + burstLeakage, + burstStartTime, + burstEndTime, + normalStartTime, + normalEndTime, + dataSource, + selectedSchemeId, + ]); + + const handleRun = async () => { + if ( + !isValid || + !burstStartTime || + !burstEndTime || + !normalStartTime || + !normalEndTime + ) { + open?.({ type: "error", message: "请完善参数并确认时间范围合法" }); + return; + } + + setRunning(true); + open?.({ + key: "burst-location-analysis", + type: "progress", + message: "方案提交分析中", + undoableTimeout: 3, + }); + + try { + const selectedScheme = + dataSource === "simulation" + ? schemes.find((item) => item.scheme_id === selectedSchemeId) + : undefined; + + const response = await api.post( + `${config.BACKEND_URL}/api/v1/burst-location/locate/`, + { + network: NETWORK_NAME, + data_source: dataSource, + scheme_name: schemeName.trim() || undefined, + burst_leakage: toM3s(burstLeakage, DMA_FLOW_DISPLAY_UNIT), + min_dpressure: minDpressure, + basic_pressure: basicPressure, + scada_burst_start: burstStartTime.toISOString(), + scada_burst_end: burstEndTime.toISOString(), + scada_normal_start: normalStartTime.toISOString(), + scada_normal_end: normalEndTime.toISOString(), + use_scada_flow: enableFlow || undefined, + simulation_scheme_name: selectedScheme?.scheme_name, + simulation_scheme_type: selectedScheme?.scheme_type, + }, + ); + + onResult(response.data as BurstLocationResult); + open?.({ + key: "burst-location-analysis", + type: "success", + message: "爆管定位成功", + description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`, + }); + } catch (error: any) { + open?.({ + key: "burst-location-analysis", + type: "error", + message: "提交分析失败", + description: error?.response?.data?.detail ?? error?.message ?? "请求失败", + }); + } finally { + setRunning(false); + } + }; + + return ( + <Box className="flex flex-col flex-1 min-h-0"> + <Box className="flex flex-col gap-3"> + <Alert severity="info"> + 选择模拟方案将自动填充爆管发生时段,监测数据模式下可手动调整时间范围。 + </Alert> + + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 方案名称 + </Typography> + <TextField + value={schemeName} + onChange={(e) => setSchemeName(e.target.value)} + placeholder="请输入方案名称" + fullWidth + size="small" + /> + </Box> + + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + SCADA 数据来源 + </Typography> + <FormControl fullWidth size="small"> + <Select + value={dataSource} + onChange={(e) => handleDataSourceChange(e.target.value as DataSource)} + > + <MenuItem value="monitoring">监测数据</MenuItem> + <MenuItem value="simulation">模拟方案</MenuItem> + </Select> + </FormControl> + </Box> + + {dataSource === "simulation" && ( + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 选择爆管分析方案 + </Typography> + <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> + <FormControl fullWidth size="small"> + <Select + value={selectedSchemeId} + onChange={(e) => handleSchemeSelect(Number(e.target.value))} + disabled={schemeLoading} + displayEmpty + > + <MenuItem value="" disabled> + 请选择方案 + </MenuItem> + {schemes.map((scheme) => ( + <MenuItem key={scheme.scheme_id} value={scheme.scheme_id}> + {scheme.scheme_name} + </MenuItem> + ))} + </Select> + </FormControl> + <IconButton + size="small" + color="primary" + onClick={() => void fetchSchemes({ force: true, notify: true })} + disabled={schemeLoading} + aria-label="刷新爆管分析方案" + sx={{ + border: "1px solid", + borderColor: "divider", + borderRadius: 1, + }} + > + {schemeLoading ? ( + <CircularProgress size={18} color="inherit" /> + ) : ( + <RefreshIcon fontSize="small" /> + )} + </IconButton> + </Box> + </Box> + )} + + <LocalizationProvider + dateAdapter={AdapterDayjs} + adapterLocale="zh-cn" + localeText={ + pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText + } + > + <Box className="grid grid-cols-2 gap-2"> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 爆管开始时间 + </Typography> + <DateTimePicker + value={burstStartTime} + onChange={setBurstStartTime} + maxDateTime={burstEndTime ?? undefined} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 爆管结束时间 + </Typography> + <DateTimePicker + value={burstEndTime} + onChange={setBurstEndTime} + minDateTime={burstStartTime ?? undefined} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 正常开始时间 + </Typography> + <DateTimePicker + value={normalStartTime} + onChange={setNormalStartTime} + maxDateTime={normalEndTime ?? undefined} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 正常结束时间 + </Typography> + <DateTimePicker + value={normalEndTime} + onChange={setNormalEndTime} + minDateTime={normalStartTime ?? undefined} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + </Box> + </LocalizationProvider> + + <Box className="flex flex-col gap-2"> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 总漏损流量 ({DMA_FLOW_DISPLAY_UNIT}) + </Typography> + <TextField + type="number" + size="small" + value={burstLeakage} + onChange={(e) => { + const value = Number(e.target.value); + setBurstLeakage(Number.isNaN(value) ? 1440 : Math.max(0, value)); + }} + fullWidth + inputProps={{ min: 0, step: 10 }} + /> + <Box + sx={{ + border: "1px solid", + borderColor: "grey.200", + borderRadius: 1, + overflow: "hidden", + }} + > + <Box + role="button" + tabIndex={0} + onClick={() => setAdvancedOpen((prev) => !prev)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setAdvancedOpen((prev) => !prev); + }} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + px: 1.25, + py: 0.75, + cursor: "pointer", + backgroundColor: "transparent", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + <Typography variant="body2" color="text.secondary"> + 高级选项 + </Typography> + <ExpandMoreIcon + sx={{ + transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)", + transition: "transform 0.2s ease", + }} + /> + </Box> + <Collapse in={advancedOpen} timeout="auto" unmountOnExit> + <Box + sx={{ + px: 1.25, + pt: 1.25, + pb: 1.25, + backgroundColor: "transparent", + }} + > + <Box className="flex flex-col gap-3"> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 流量校核 + </Typography> + <FormControl fullWidth size="small"> + <Select + value={enableFlow ? "enabled" : "disabled"} + onChange={(e) => setEnableFlow(e.target.value === "enabled")} + > + <MenuItem value="disabled">禁用</MenuItem> + <MenuItem value="enabled">启用(使用流量计)</MenuItem> + </Select> + </FormControl> + </Box> + <Box className="grid grid-cols-2 gap-2"> + <TextField + type="number" + label="最小压降 (m)" + size="small" + value={minDpressure} + onChange={(e) => setMinDpressure(Number(e.target.value))} + /> + <TextField + type="number" + label="基础压力 (m)" + size="small" + value={basicPressure} + onChange={(e) => setBasicPressure(Number(e.target.value))} + /> + </Box> + </Box> + </Box> + </Collapse> + </Box> + </Box> + </Box> + + <Box className="mt-auto pt-3"> + <Button + fullWidth + variant="contained" + onClick={handleRun} + disabled={!isValid || running} + className="bg-blue-600 hover:bg-blue-700" + > + {running ? "定位中..." : "开始定位"} + </Button> + </Box> + </Box> + ); +}; + +export default AnalysisParameters; diff --git a/src/components/olmap/BurstLocation/BurstLocationPanel.tsx b/src/components/olmap/BurstLocation/BurstLocationPanel.tsx new file mode 100644 index 0000000..fe56b8f --- /dev/null +++ b/src/components/olmap/BurstLocation/BurstLocationPanel.tsx @@ -0,0 +1,162 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { Box, Drawer, IconButton, Tab, Tabs, Tooltip, Typography } from "@mui/material"; +import { + Analytics as AnalyticsIcon, + ChevronLeft, + ChevronRight, + FormatListBulleted, + Search as SearchIcon, +} from "@mui/icons-material"; +import AnalysisParameters from "./AnalysisParameters"; +import LocationResults from "./LocationResults"; +import SchemeQuery from "./SchemeQuery"; +import { BurstLocationResult } from "./types"; + +const TabPanel = ({ + value, + index, + children, +}: { + value: number; + index: number; + children: React.ReactNode; +}) => ( + <div role="tabpanel" hidden={value !== index} className="flex-1 overflow-hidden flex flex-col"> + {value === index ? <Box className="flex-1 overflow-auto p-4 flex flex-col">{children}</Box> : null} + </div> +); + +const BurstLocationPanel: React.FC = () => { + const [open, setOpen] = useState(true); + const [tab, setTab] = useState(0); + const [result, setResult] = useState<BurstLocationResult | null>(null); + + const drawerWidth = 450; + const panelTitle = "爆管定位"; + + const handleResult = useCallback((payload: BurstLocationResult) => { + setResult(payload); + setTab(2); + }, []); + + const handleViewResult = useCallback((payload: BurstLocationResult) => { + setResult(payload); + setTab(2); + }, []); + + return ( + <> + {!open && ( + <Box + className="absolute top-4 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100" + onClick={() => setOpen(true)} + sx={{ zIndex: 1300 }} + > + <Box className="flex flex-col items-center py-3 px-3 gap-1"> + <AnalyticsIcon className="text-[#257DD4] w-5 h-5" /> + <Typography + variant="caption" + className="text-gray-700 font-semibold my-1 text-xs" + style={{ writingMode: "vertical-rl" }} + > + {panelTitle} + </Typography> + <ChevronLeft className="text-gray-600 w-4 h-4" /> + </Box> + </Box> + )} + + <Drawer + anchor="right" + open={open} + variant="persistent" + hideBackdrop + sx={{ + width: 0, + flexShrink: 0, + "& .MuiDrawer-paper": { + width: drawerWidth, + boxSizing: "border-box", + position: "absolute", + top: 16, + right: 16, + height: "calc(100vh - 32px)", + maxHeight: "850px", + borderRadius: "12px", + boxShadow: + "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", + backdropFilter: "blur(8px)", + opacity: 0.95, + transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out", + border: "none", + "&:hover": { + opacity: 1, + }, + }, + }} + > + <Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden"> + <Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white"> + <Box className="flex items-center gap-2"> + <AnalyticsIcon className="w-5 h-5" /> + <Typography variant="h6" className="text-lg font-semibold"> + {panelTitle} + </Typography> + </Box> + <Tooltip title="收起"> + <IconButton + size="small" + onClick={() => setOpen(false)} + sx={{ color: "primary.contrastText" }} + > + <ChevronRight fontSize="small" /> + </IconButton> + </Tooltip> + </Box> + + <Box className="border-b border-gray-200 bg-white"> + <Tabs + value={tab} + onChange={(_, value) => setTab(value)} + variant="fullWidth" + sx={{ + minHeight: 48, + "& .MuiTab-root": { + minHeight: 48, + textTransform: "none", + fontSize: "0.875rem", + fontWeight: 500, + transition: "all 0.2s", + }, + "& .Mui-selected": { + color: "#257DD4", + }, + "& .MuiTabs-indicator": { + backgroundColor: "#257DD4", + }, + }} + > + <Tab icon={<AnalyticsIcon fontSize="small" />} iconPosition="start" label="定位参数" /> + <Tab icon={<SearchIcon fontSize="small" />} iconPosition="start" label="方案查询" /> + <Tab icon={<FormatListBulleted fontSize="small" />} iconPosition="start" label="定位结果" /> + </Tabs> + </Box> + + <TabPanel value={tab} index={0}> + <AnalysisParameters onResult={handleResult} /> + </TabPanel> + <TabPanel value={tab} index={1}> + <SchemeQuery onViewResult={handleViewResult} /> + </TabPanel> + <TabPanel value={tab} index={2}> + <LocationResults result={result} /> + </TabPanel> + </Box> + </Drawer> + </> + ); +}; + +export default BurstLocationPanel; diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx new file mode 100644 index 0000000..98d54d7 --- /dev/null +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -0,0 +1,267 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { + Box, + Button, + Chip, + Divider, + IconButton, + Paper, + Tooltip, + Typography +} from "@mui/material"; +import { + FormatListBulleted, + LocationOn as LocationOnIcon, + Map as MapIcon +} from "@mui/icons-material"; +import dayjs from "dayjs"; +import { useMap } from "@app/OlMap/MapComponent"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { GeoJSON } from "ol/format"; +import Feature from "ol/Feature"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Stroke, Style, Circle, Fill } from "ol/style"; +import { bbox, featureCollection } from "@turf/turf"; +import { BurstLocationResult } from "./types"; + +interface Props { + result: BurstLocationResult | null; +} + +const LocationResults: React.FC<Props> = ({ result }) => { + const map = useMap(); + const [highlightLayer, setHighlightLayer] = + useState<VectorLayer<VectorSource> | null>(null); + const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); + + const candidatePipes = useMemo(() => { + if (!result) return []; + const base = result.top_candidates ?? []; + const hasLocated = base.some((item) => item.pipe_id === result.located_pipe); + if (result.located_pipe && !hasLocated) { + return [{ pipe_id: result.located_pipe, similarity: 1.0 }, ...base]; + } + return base; + }, [result]); + + useEffect(() => { + if (!map) return; + + const layer = new VectorLayer({ + source: new VectorSource(), + style: new Style({ + stroke: new Stroke({ + color: "#ef4444", + width: 6, + }), + image: new Circle({ + radius: 8, + fill: new Fill({ color: "#ef4444" }), + stroke: new Stroke({ color: "#fff", width: 2 }), + }), + zIndex: 999, + }), + properties: { + name: "爆管定位高亮", + value: "burst_location_highlight", + }, + }); + map.addLayer(layer); + setHighlightLayer(layer); + + return () => { + map.removeLayer(layer); + }; + }, [map]); + + useEffect(() => { + const source = highlightLayer?.getSource(); + if (!source) return; + source.clear(); + highlightFeatures.forEach((feature) => source.addFeature(feature)); + }, [highlightFeatures, highlightLayer]); + + const locatePipes = async (pipeIds: string[]) => { + if (!pipeIds.length || !map) return; + + try { + // 尝试两个图层 + let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat"); + if (features.length === 0) { + features = await queryFeaturesByIds(pipeIds, "geo_pipes"); + } + + if (features.length === 0) return; + + setHighlightFeatures(features); + + const geojsonFormat = new GeoJSON(); + const geojsonFeatures = features.map((feature) => + geojsonFormat.writeFeatureObject(feature), + ); + // @ts-ignore + const extent = bbox(featureCollection(geojsonFeatures)); + map.getView().fit(extent, { + maxZoom: 19, + duration: 1000, + padding: [100, 100, 100, 100], + }); + } catch (e) { + console.error("Locate failed", e); + } + }; + + if (!result) { + return ( + <Box className="flex flex-col items-center justify-center h-full bg-gray-50/50 p-6 text-center"> + <Box className="bg-white p-6 rounded-full shadow-sm mb-4"> + <MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> + </Box> + <Typography variant="h6" className="text-gray-700 font-bold mb-1">等待定位</Typography> + <Typography variant="body2" className="text-gray-500 max-w-xs"> + 请在左侧面板配置传感器参数与时间范围,点击“开始定位”获取结果。 + </Typography> + </Box> + ); + } + + return ( + <Box className="h-full overflow-y-auto bg-gray-50 p-3 space-y-3"> + {/* 1. 冠军卡片 */} + <Box className="flex items-center justify-between px-1 mb-2"> + <Box className="flex items-center gap-2"> + <Box className="w-1 h-4 bg-blue-600 rounded-full" /> + <Typography + variant="h6" + className="font-bold text-gray-900 truncate" + sx={{ fontSize: "1.1rem" }} + title={result.scheme_name || "Burst Location Result"} + > + {result.scheme_name || "爆管定位结果"} + </Typography> + </Box> + {result.username && ( + <Chip + label={result.username} + size="small" + sx={{ + height: 24, + backgroundColor: "#f3f4f6", + color: "#4b5563", + border: "none", + fontWeight: 500 + }} + /> + )} + </Box> + + {/* 2. 统计数据 */} + <Box className="grid grid-cols-2 gap-3 mb-4"> + {/* 方案时间/创建时间 */} + <Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm"> + <Typography variant="caption" className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> + 方案时间 + </Typography> + <Typography variant="body2" className="font-bold text-blue-900"> + {result.create_time ? dayjs(result.create_time).format("MM-DD HH:mm") : "-"} + </Typography> + </Box> + + {/* 漏损量 */} + <Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm"> + <Typography variant="caption" className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> + 漏损量 + </Typography> + <Typography variant="body2" className="font-bold text-orange-900"> + {result.burst_leakage.toFixed(1)} L/s + </Typography> + </Box> + + {/* 最佳匹配 */} + <Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm col-span-2"> + <Box className="flex items-center justify-between"> + <Box> + <Typography variant="caption" className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> + 最佳匹配管段 + </Typography> + <Typography variant="h6" className="font-bold text-purple-900"> + {result.located_pipe} + </Typography> + <Typography variant="caption" className="text-purple-600"> + 置信度: {(candidatePipes[0]?.similarity * 100 || 0).toFixed(1)}% · 模式: {result.similarity_mode} + </Typography> + </Box> + <Button + size="small" + variant="contained" + color="secondary" + startIcon={<LocationOnIcon />} + onClick={() => locatePipes([result.located_pipe])} + sx={{ backgroundColor: "#9333ea", "&:hover": { backgroundColor: "#7e22ce" } }} + > + 定位 + </Button> + </Box> + </Box> + </Box> + + {/* 3. 候选列表 */} + <Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden"> + <Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white"> + <Box className="flex items-center gap-2"> + <FormatListBulleted className="text-blue-600 w-5 h-5" /> + <Typography variant="subtitle1" className="font-bold text-gray-800"> + 候选管段列表 + </Typography> + </Box> + <Chip + size="small" + label={`${candidatePipes.length} 条`} + sx={{ + height: 22, + backgroundColor: "rgba(37, 99, 235, 0.08)", + color: "#2563eb", + fontWeight: 600, + fontSize: "0.75rem", + border: "none", + }} + /> + </Box> + <Box className="max-h-64 overflow-y-auto"> + {candidatePipes.map((candidate, idx) => ( + <Box + key={candidate.pipe_id} + className="flex items-center justify-between px-4 py-3 border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors" + > + <Box className="flex items-center gap-3"> + <Box className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${idx === 0 ? "bg-yellow-100 text-yellow-700" : idx === 1 ? "bg-gray-100 text-gray-600" : idx === 2 ? "bg-orange-50 text-orange-600" : "bg-transparent text-gray-400"}`}> + {idx + 1} + </Box> + <Box> + <Typography variant="body2" className="font-bold text-gray-700"> + {candidate.pipe_id} + </Typography> + <Typography variant="caption" className="text-gray-400"> + 相似度: {(candidate.similarity * 100).toFixed(2)}% + </Typography> + </Box> + </Box> + <IconButton + size="small" + onClick={() => locatePipes([candidate.pipe_id])} + className="text-gray-400 hover:text-blue-600" + > + <LocationOnIcon fontSize="small" /> + </IconButton> + </Box> + ))} + </Box> + </Box> + </Box> + ); +}; + +export default LocationResults; diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx new file mode 100644 index 0000000..f24dbb1 --- /dev/null +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -0,0 +1,253 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + Chip, + Collapse, + FormControlLabel, + Checkbox, + IconButton, + Tooltip, + Typography, +} from "@mui/material"; +import { Info as InfoIcon } from "@mui/icons-material"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import "dayjs/locale/zh-cn"; +import dayjs, { Dayjs } from "dayjs"; +import { useNotification } from "@refinedev/core"; +import { api } from "@/lib/api"; +import { NETWORK_NAME, config } from "@config/config"; +import { BurstLocationResult, BurstSchemeRecord } from "./types"; + +interface Props { + onViewResult: (result: BurstLocationResult) => void; +} + +const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { + const { open } = useNotification(); + const [queryAll, setQueryAll] = useState(true); + const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); + const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState<number | null>(null); + + const handleQuery = async () => { + setLoading(true); + try { + // API call to fetch schemes + // Adjust URL as needed + let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`; + const params: Record<string, string> = { network: NETWORK_NAME }; + if (!queryAll && queryDate) { + params.query_date = queryDate.startOf("day").toISOString(); + } + + const response = await api.get(url, { params }); + setSchemes(response.data); + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${response.data.length} 条记录`, + }); + } catch (error: any) { + console.error(error); + open?.({ + type: "error", + message: "查询失败", + description: error?.response?.data?.detail ?? "无法获取方案列表", + }); + } finally { + setLoading(false); + } + }; + + const handleViewSchemeResult = async (schemeName: string) => { + try { + const response = await api.get( + `${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`, + { params: { network: NETWORK_NAME } }, + ); + // The backend returns { scheme_detail: ... } inside the response or just the result? + // Based on burst_location.py: get_burst_location_scheme_detail returns the stored detail. + // Let's assume response.data is the BurstLocationResult + onViewResult(response.data as BurstLocationResult); + open?.({ + type: "success", + message: "方案加载成功", + description: `已加载方案: ${schemeName}`, + }); + } catch (error: any) { + open?.({ + type: "error", + message: "查看详情失败", + description: error?.response?.data?.detail ?? "无法获取方案详情", + }); + } + }; + + return ( + <Box className="flex flex-col h-full"> + <Box className="mb-2 p-2 bg-gray-50 rounded"> + <Box className="flex items-center gap-2 justify-between"> + <Box className="flex items-center gap-2"> + <FormControlLabel + control={ + <Checkbox + size="small" + checked={queryAll} + onChange={(e) => setQueryAll(e.target.checked)} + /> + } + label={<Typography variant="body2">查询全部</Typography>} + className="m-0" + /> + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> + <DatePicker + value={queryDate} + onChange={setQueryDate} + disabled={queryAll} + format="YYYY-MM-DD" + slotProps={{ textField: { size: "small", sx: { width: 200 } } }} + /> + </LocalizationProvider> + </Box> + <Button + variant="contained" + onClick={handleQuery} + disabled={loading} + size="small" + className="bg-blue-600 hover:bg-blue-700" + sx={{ minWidth: 80 }} + > + {loading ? "查询中..." : "查询"} + </Button> + </Box> + </Box> + <Box className="flex-1 overflow-auto"> + {schemes.length === 0 ? ( + <Box className="flex flex-col items-center justify-center h-full text-gray-400"> + <Box className="mb-4"> + <svg + width="80" + height="80" + viewBox="0 0 80 80" + fill="none" + className="opacity-40" + > + <rect + x="10" + y="20" + width="60" + height="45" + rx="2" + stroke="currentColor" + strokeWidth="2" + /> + <line + x1="10" + y1="30" + x2="70" + y2="30" + stroke="currentColor" + strokeWidth="2" + /> + </svg> + </Box> + <Typography variant="body2">总共 0 条</Typography> + <Typography variant="body2" className="mt-1"> + No data + </Typography> + </Box> + ) : ( + <Box className="space-y-2 p-2"> + <Typography variant="caption" className="text-gray-500 px-2"> + 共 {schemes.length} 条记录 + </Typography> + {schemes.map((scheme) => ( + <Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow"> + <CardContent className="p-3 pb-2 last:pb-3"> + <Box className="flex items-start justify-between gap-2 mb-2"> + <Box className="flex-1 min-w-0"> + <Box className="flex items-center gap-2 mb-1"> + <Typography variant="body2" className="font-medium truncate" title={scheme.scheme_name}> + {scheme.scheme_name} + </Typography> + <Chip size="small" variant="outlined" color="primary" label="爆管定位" className="h-5" /> + </Box> + <Typography variant="caption" className="text-gray-500 block"> + ID: {scheme.scheme_id} · 日期: {dayjs(scheme.create_time).format("MM-DD HH:mm")} + </Typography> + </Box> + <Box className="flex gap-1 ml-2"> + <Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}> + <IconButton + size="small" + onClick={() => setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)} + color="primary" + className="p-1" + > + <InfoIcon fontSize="small" /> + </IconButton> + </Tooltip> + </Box> + </Box> + <Collapse in={expandedId === scheme.scheme_id}> + <Box className="mt-2 pt-3 border-t border-gray-200"> + <Box className="mb-3 rounded-md bg-gray-50 px-3 py-2 space-y-2"> + {/* Summary details */} + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 定位管段: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {scheme.scheme_detail?.located_pipe || "-"} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 漏损量: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {scheme.scheme_detail?.burst_leakage ? `${scheme.scheme_detail.burst_leakage} L/s` : "-"} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 用户: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {scheme.username || "-"} + </Typography> + </Box> + </Box> + <Box className="pt-2 border-t border-gray-100"> + <Button + variant="contained" + fullWidth + size="small" + className="bg-blue-600 hover:bg-blue-700" + sx={{ textTransform: "none", fontWeight: 500 }} + onClick={() => handleViewSchemeResult(scheme.scheme_name)} + > + 查看定位结果 + </Button> + </Box> + </Box> + </Collapse> + </CardContent> + </Card> + ))} + </Box> + )} + </Box> + </Box> + ); +}; + +export default SchemeQuery; diff --git a/src/components/olmap/BurstLocation/types.ts b/src/components/olmap/BurstLocation/types.ts new file mode 100644 index 0000000..da03b0a --- /dev/null +++ b/src/components/olmap/BurstLocation/types.ts @@ -0,0 +1,27 @@ +export interface BurstCandidate { + pipe_id: string; + similarity: number; +} + +export interface BurstLocationResult { + located_pipe: string; + burst_leakage: number; + elapsed_seconds: number; + simulation_times: number; + top_candidates: BurstCandidate[]; + similarity_mode: string; + scheme_name?: string; + username?: string; + observed_source?: string; + pressure_scada_ids?: string[]; + flow_scada_ids?: string[]; + create_time?: string; +} + +export interface BurstSchemeRecord { + scheme_id: number; + scheme_name: string; + create_time: string; + username?: string; + scheme_detail?: BurstLocationResult; +} diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 1c45f16..4a6aa7a 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -78,7 +78,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { key: "dma-leak-analysis", type: "success", message: "方案分析成功", - description: "DMA漏损识别完成,请在方案查询中查看结果。", + description: "DMA 漏损识别完成,请在方案查询中查看结果。", }); } catch (error: any) { open?.({ diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index 75cb5a0..24b9e4c 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -54,7 +54,7 @@ const DMALeakDetectionPanel: React.FC = () => { const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null); const drawerWidth = 450; - const panelTitle = "DMA漏损识别"; + const panelTitle = "DMA 漏损识别"; const activeAreas = loadedResult?.areas ?? []; const legendColors = useMemo( () => activeAreas.map((area) => getAreaColor(area.area_id)), diff --git a/src/components/olmap/DMALeakDetection/utils.ts b/src/components/olmap/DMALeakDetection/utils.ts index c38ee6f..2d56f73 100644 --- a/src/components/olmap/DMALeakDetection/utils.ts +++ b/src/components/olmap/DMALeakDetection/utils.ts @@ -11,7 +11,7 @@ export const AREA_COLORS = [ "#be123c", ]; -export const DMA_FLOW_DISPLAY_UNIT = "m3/h"; +export const DMA_FLOW_DISPLAY_UNIT = "m³/h"; const M3H_FACTOR = 3600; export const getAreaColor = (areaId: string | number | undefined) => { @@ -23,16 +23,16 @@ export const getAreaColor = (areaId: string | number | undefined) => { return AREA_COLORS[hash % AREA_COLORS.length]; }; -export const toM3h = (value: number, sourceUnit: string = "m3/s") => { +export const toM3h = (value: number, sourceUnit: string = "m³/s") => { if (!Number.isFinite(value)) return Number.NaN; const normalizedUnit = sourceUnit.trim().toLowerCase(); - if (normalizedUnit === "m3/h") return value; + if (normalizedUnit === "m³/h") return value; return value * M3H_FACTOR; }; -export const toM3s = (value: number, sourceUnit: string = "m3/h") => { +export const toM3s = (value: number, sourceUnit: string = "m³/h") => { if (!Number.isFinite(value)) return Number.NaN; const normalizedUnit = sourceUnit.trim().toLowerCase(); - if (normalizedUnit === "m3/s") return value; + if (normalizedUnit === "m³/s") return value; return value / M3H_FACTOR; }; -- 2.54.0 From 133880f7fc67893379321057a8f89a49d171404b Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 11:47:27 +0800 Subject: [PATCH 034/281] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/BurstLocation/AnalysisParameters.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 7b47ddc..b879396 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -234,10 +234,6 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { return ( <Box className="flex flex-col flex-1 min-h-0"> <Box className="flex flex-col gap-3"> - <Alert severity="info"> - 选择模拟方案将自动填充爆管发生时段,监测数据模式下可手动调整时间范围。 - </Alert> - <Box> <Typography variant="subtitle2" className="mb-1 font-medium"> 方案名称 -- 2.54.0 From 2f24ab5d66b756c084dfff3c628b9822407c6839 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 13:54:15 +0800 Subject: [PATCH 035/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=88=86=E7=AE=A1?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=A4=84=E7=90=86=E5=92=8C=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BurstLocation/AnalysisParameters.tsx | 20 +- .../olmap/BurstLocation/LocationResults.tsx | 418 +++++++++++------- .../olmap/BurstLocation/SchemeQuery.tsx | 241 ++++++---- src/components/olmap/BurstLocation/types.ts | 50 ++- 4 files changed, 498 insertions(+), 231 deletions(-) diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index b879396..96f2d60 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -1,10 +1,9 @@ "use client"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import RefreshIcon from "@mui/icons-material/Refresh"; import { - Alert, Box, Button, CircularProgress, @@ -70,6 +69,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { const [basicPressure, setBasicPressure] = useState<number>(10); const [advancedOpen, setAdvancedOpen] = useState(false); const [running, setRunning] = useState(false); + const isSimulationMode = dataSource === "simulation"; const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { const start = dayjs(scheme.scheme_start_time); @@ -78,10 +78,16 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setBurstStartTime(start); setBurstEndTime(end); - setNormalStartTime(start.subtract(2, "hour")); - setNormalEndTime(start.subtract(10, "minute")); + setNormalStartTime(start); + setNormalEndTime(end); }, []); + useEffect(() => { + if (!isSimulationMode) return; + setNormalStartTime(burstStartTime); + setNormalEndTime(burstEndTime); + }, [burstEndTime, burstStartTime, isSimulationMode]); + const fetchSchemes = useCallback( async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { if (schemeLoading || (!force && schemes.length > 0)) return; @@ -262,7 +268,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </FormControl> </Box> - {dataSource === "simulation" && ( + {isSimulationMode && ( <Box> <Typography variant="subtitle2" className="mb-1 font-medium"> 选择爆管分析方案 @@ -323,6 +329,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={burstStartTime} onChange={setBurstStartTime} maxDateTime={burstEndTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> @@ -335,6 +342,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={burstEndTime} onChange={setBurstEndTime} minDateTime={burstStartTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> @@ -347,6 +355,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={normalStartTime} onChange={setNormalStartTime} maxDateTime={normalEndTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> @@ -359,6 +368,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={normalEndTime} onChange={setNormalEndTime} minDateTime={normalStartTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index 98d54d7..35a983d 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -1,20 +1,22 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; -import { - Box, - Button, - Chip, - Divider, - IconButton, - Paper, - Tooltip, - Typography +import { + Box, + Typography, + Chip, + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Button, } from "@mui/material"; import { FormatListBulleted, - LocationOn as LocationOnIcon, - Map as MapIcon + LocationOn as LocationOnIcon, + Map as MapIcon, } from "@mui/icons-material"; import dayjs from "dayjs"; import { useMap } from "@app/OlMap/MapComponent"; @@ -25,24 +27,102 @@ import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Stroke, Style, Circle, Fill } from "ol/style"; import { bbox, featureCollection } from "@turf/turf"; -import { BurstLocationResult } from "./types"; +import { BurstCandidate, BurstLocationResult } from "./types"; +import { DMA_FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface Props { result: BurstLocationResult | null; } +interface MetricCardProps { + label: string; + value: string; + hint?: string; + tone: "blue" | "orange" | "purple" | "green"; +} + +const toneStyles: Record< + MetricCardProps["tone"], + { bg: string; border: string; text: string; darkText: string } +> = { + blue: { + bg: "from-blue-50 to-blue-100", + border: "border-blue-200", + text: "text-blue-700", + darkText: "text-blue-900", + }, + orange: { + bg: "from-orange-50 to-orange-100", + border: "border-orange-200", + text: "text-orange-700", + darkText: "text-orange-900", + }, + purple: { + bg: "from-purple-50 to-purple-100", + border: "border-purple-200", + text: "text-purple-700", + darkText: "text-purple-900", + }, + green: { + bg: "from-green-50 to-green-100", + border: "border-green-200", + text: "text-green-700", + darkText: "text-green-900", + }, +}; + +const formatDateTime = (value?: string) => + value ? dayjs(value).format("MM-DD HH:mm") : "-"; + +const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { + const style = toneStyles[tone]; + return ( + <Box + className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`} + > + <Typography + variant="caption" + className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`} + > + {label} + </Typography> + <Typography variant="body2" className={`font-bold ${style.darkText}`}> + {value} + </Typography> + {hint ? ( + <Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}> + {hint} + </Typography> + ) : null} + </Box> + ); +}; + +const EmptyState = () => ( + <Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> + <Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> + <MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> + </Box> + <Typography variant="h6" className="mb-1 font-bold text-gray-700"> + 等待定位结果 + </Typography> + <Typography variant="body2" className="max-w-xs text-gray-500"> + 请先提交爆管定位分析,结果面板将展示定位摘要、时间窗、采样情况和候选管段。 + </Typography> + </Box> +); + const LocationResults: React.FC<Props> = ({ result }) => { const map = useMap(); - const [highlightLayer, setHighlightLayer] = - useState<VectorLayer<VectorSource> | null>(null); + const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); - const candidatePipes = useMemo(() => { + const candidatePipes = useMemo<BurstCandidate[]>(() => { if (!result) return []; const base = result.top_candidates ?? []; const hasLocated = base.some((item) => item.pipe_id === result.located_pipe); if (result.located_pipe && !hasLocated) { - return [{ pipe_id: result.located_pipe, similarity: 1.0 }, ...base]; + return [{ pipe_id: result.located_pipe, similarity: 1 }, ...base]; } return base; }, [result]); @@ -86,133 +166,124 @@ const LocationResults: React.FC<Props> = ({ result }) => { const locatePipes = async (pipeIds: string[]) => { if (!pipeIds.length || !map) return; - + try { - // 尝试两个图层 let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat"); if (features.length === 0) { features = await queryFeaturesByIds(pipeIds, "geo_pipes"); } - if (features.length === 0) return; - + setHighlightFeatures(features); - + const geojsonFormat = new GeoJSON(); - const geojsonFeatures = features.map((feature) => - geojsonFormat.writeFeatureObject(feature), - ); - // @ts-ignore + const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); + // @ts-ignore turf typing with ol geojson objects const extent = bbox(featureCollection(geojsonFeatures)); map.getView().fit(extent, { maxZoom: 19, duration: 1000, padding: [100, 100, 100, 100], }); - } catch (e) { - console.error("Locate failed", e); + } catch (error) { + console.error("Locate failed", error); } }; if (!result) { - return ( - <Box className="flex flex-col items-center justify-center h-full bg-gray-50/50 p-6 text-center"> - <Box className="bg-white p-6 rounded-full shadow-sm mb-4"> - <MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> - </Box> - <Typography variant="h6" className="text-gray-700 font-bold mb-1">等待定位</Typography> - <Typography variant="body2" className="text-gray-500 max-w-xs"> - 请在左侧面板配置传感器参数与时间范围,点击“开始定位”获取结果。 - </Typography> - </Box> - ); + return <EmptyState />; } + const burstSamples = result.pressure_samples?.burst ?? 0; + const normalSamples = result.pressure_samples?.normal ?? 0; + const elapsedText = + result.elapsed_seconds && result.elapsed_seconds > 0 + ? `${result.elapsed_seconds.toFixed(1)} s` + : "-"; + const bestSimilarity = candidatePipes[0]?.similarity ?? 0; + const burstTime = result.scada_window?.burst_start + ? formatDateTime(result.scada_window.burst_start) + : "-"; + return ( - <Box className="h-full overflow-y-auto bg-gray-50 p-3 space-y-3"> - {/* 1. 冠军卡片 */} - <Box className="flex items-center justify-between px-1 mb-2"> - <Box className="flex items-center gap-2"> - <Box className="w-1 h-4 bg-blue-600 rounded-full" /> - <Typography - variant="h6" - className="font-bold text-gray-900 truncate" - sx={{ fontSize: "1.1rem" }} - title={result.scheme_name || "Burst Location Result"} - > - {result.scheme_name || "爆管定位结果"} - </Typography> - </Box> - {result.username && ( - <Chip - label={result.username} - size="small" - sx={{ - height: 24, - backgroundColor: "#f3f4f6", - color: "#4b5563", - border: "none", - fontWeight: 500 - }} - /> - )} - </Box> - - {/* 2. 统计数据 */} - <Box className="grid grid-cols-2 gap-3 mb-4"> - {/* 方案时间/创建时间 */} - <Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm"> - <Typography variant="caption" className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> - 方案时间 - </Typography> - <Typography variant="body2" className="font-bold text-blue-900"> - {result.create_time ? dayjs(result.create_time).format("MM-DD HH:mm") : "-"} - </Typography> - </Box> - - {/* 漏损量 */} - <Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm"> - <Typography variant="caption" className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> - 漏损量 - </Typography> - <Typography variant="body2" className="font-bold text-orange-900"> - {result.burst_leakage.toFixed(1)} L/s - </Typography> - </Box> - - {/* 最佳匹配 */} - <Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm col-span-2"> - <Box className="flex items-center justify-between"> - <Box> - <Typography variant="caption" className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide"> - 最佳匹配管段 - </Typography> - <Typography variant="h6" className="font-bold text-purple-900"> - {result.located_pipe} - </Typography> - <Typography variant="caption" className="text-purple-600"> - 置信度: {(candidatePipes[0]?.similarity * 100 || 0).toFixed(1)}% · 模式: {result.similarity_mode} - </Typography> - </Box> - <Button - size="small" - variant="contained" - color="secondary" - startIcon={<LocationOnIcon />} - onClick={() => locatePipes([result.located_pipe])} - sx={{ backgroundColor: "#9333ea", "&:hover": { backgroundColor: "#7e22ce" } }} - > - 定位 - </Button> - </Box> - </Box> - </Box> - - {/* 3. 候选列表 */} - <Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden"> - <Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white"> + <Box className="h-full overflow-auto p-1"> + {/* Header & Metrics */} + <Box className="mb-4 space-y-3"> + <Box className="flex items-center justify-between px-1"> <Box className="flex items-center gap-2"> - <FormatListBulleted className="text-blue-600 w-5 h-5" /> + <Box className="h-4 w-1 rounded-full bg-blue-600" /> + <Typography + variant="h6" + className="truncate font-bold text-gray-900" + sx={{ fontSize: "1.1rem" }} + title={result.scheme_name} + > + {result.scheme_name || "爆管定位结果"} + </Typography> + </Box> + <Box className="flex items-center gap-2"> + {result.username ? ( + <Chip + label={result.username} + size="small" + sx={{ + height: 24, + backgroundColor: "#f3f4f6", + color: "#4b5563", + border: "none", + fontWeight: 500, + }} + /> + ) : null} + <Button + size="small" + variant="outlined" + startIcon={<LocationOnIcon />} + onClick={() => locatePipes([result.located_pipe])} + sx={{ + height: 24, + minWidth: 0, + padding: "0 8px", + borderColor: "#bfdbfe", + color: "#2563eb", + fontSize: "0.75rem", + "&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" }, + }} + > + 定位 + </Button> + </Box> + </Box> + + <Box className="grid grid-cols-2 gap-3"> + <MetricCard + label="定位管段" + value={result.located_pipe || "-"} + tone="blue" + /> + <MetricCard + label="估计漏损量" + value={`${result.burst_leakage.toFixed(2)} ${DMA_FLOW_DISPLAY_UNIT}`} + tone="orange" + /> + <MetricCard + label="最佳相似度" + value={`${(bestSimilarity * 100).toFixed(1)}%`} + tone="purple" + /> + <MetricCard + label="爆管时间" + value={burstTime} + tone="green" + /> + </Box> + </Box> + + {/* Candidate List */} + <Box className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> + <Box className="flex items-center gap-2"> + <FormatListBulleted className="h-5 w-5 text-blue-600" /> <Typography variant="subtitle1" className="font-bold text-gray-800"> 候选管段列表 </Typography> @@ -230,35 +301,84 @@ const LocationResults: React.FC<Props> = ({ result }) => { }} /> </Box> - <Box className="max-h-64 overflow-y-auto"> - {candidatePipes.map((candidate, idx) => ( - <Box - key={candidate.pipe_id} - className="flex items-center justify-between px-4 py-3 border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors" - > - <Box className="flex items-center gap-3"> - <Box className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${idx === 0 ? "bg-yellow-100 text-yellow-700" : idx === 1 ? "bg-gray-100 text-gray-600" : idx === 2 ? "bg-orange-50 text-orange-600" : "bg-transparent text-gray-400"}`}> - {idx + 1} - </Box> - <Box> - <Typography variant="body2" className="font-bold text-gray-700"> - {candidate.pipe_id} - </Typography> - <Typography variant="caption" className="text-gray-400"> - 相似度: {(candidate.similarity * 100).toFixed(2)}% - </Typography> - </Box> - </Box> - <IconButton - size="small" - onClick={() => locatePipes([candidate.pipe_id])} - className="text-gray-400 hover:text-blue-600" + <Table size="small"> + <TableHead> + <TableRow sx={{ backgroundColor: "#f8fafc" }}> + <TableCell sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pl: 3 }}> + 排名 + </TableCell> + <TableCell sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }}> + 管段 ID + </TableCell> + <TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }}> + 相似度 + </TableCell> + <TableCell align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }}> + 操作 + </TableCell> + </TableRow> + </TableHead> + <TableBody> + {candidatePipes.map((candidate, index) => { + const similarityPercent = candidate.similarity * 100; + const isTop = index === 0; + return ( + <TableRow + key={candidate.pipe_id} + hover + sx={{ + "&:last-child td, &:last-child th": { border: 0 }, + backgroundColor: isTop ? "#eff6ff" : "inherit", + }} + className="transition-colors" > - <LocationOnIcon fontSize="small" /> - </IconButton> - </Box> - ))} - </Box> + <TableCell sx={{ pl: 3, py: 1.2 }}> + <Box + className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${isTop ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-600" + }`} + > + {index + 1} + </Box> + </TableCell> + <TableCell sx={{ py: 1.2 }}> + <Typography + variant="body2" + className={`font-medium ${isTop ? "text-blue-700" : "text-gray-700"}`} + > + {candidate.pipe_id} + </Typography> + </TableCell> + <TableCell align="right" sx={{ py: 1.2 }}> + <Box className="flex flex-col items-end gap-1"> + <Typography + variant="body2" + className={`font-medium ${isTop ? "text-blue-700" : "text-gray-700"}`} + > + {similarityPercent.toFixed(2)}% + </Typography> + <Box className="h-1.5 w-24 overflow-hidden rounded-full bg-gray-100"> + <Box + className={`h-full rounded-full ${isTop ? "bg-blue-500" : "bg-gray-400"}`} + style={{ width: `${similarityPercent}%` }} + /> + </Box> + </Box> + </TableCell> + <TableCell align="right" sx={{ pr: 3, py: 1.2 }}> + <IconButton + size="small" + onClick={() => locatePipes([candidate.pipe_id])} + className="text-blue-600 hover:bg-blue-50" + title="定位" + > + <LocationOnIcon fontSize="small" /> + </IconButton> + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> </Box> </Box> ); diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index f24dbb1..4aa7296 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -23,7 +23,12 @@ import dayjs, { Dayjs } from "dayjs"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; -import { BurstLocationResult, BurstSchemeRecord } from "./types"; +import { + BurstLocationResult, + BurstLocationSchemeDetail, + BurstSchemeRecord, +} from "./types"; +import { DMA_FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface Props { onViewResult: (result: BurstLocationResult) => void; @@ -37,6 +42,39 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { const [loading, setLoading] = useState(false); const [expandedId, setExpandedId] = useState<number | null>(null); + const buildDisplayResult = ( + scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">, + detail?: BurstLocationSchemeDetail, + ): BurstLocationResult | null => { + const payload = detail?.result_payload; + const locatedPipe = payload?.located_pipe ?? detail?.result_summary?.located_pipe; + if (!locatedPipe) return null; + + return { + located_pipe: locatedPipe, + burst_leakage: payload?.burst_leakage ?? detail?.algorithm_params?.burst_leakage ?? 0, + elapsed_seconds: payload?.elapsed_seconds ?? 0, + min_dpressure: payload?.min_dpressure ?? detail?.algorithm_params?.min_dpressure, + basic_pressure: payload?.basic_pressure ?? detail?.algorithm_params?.basic_pressure, + simulation_times: payload?.simulation_times ?? detail?.result_summary?.simulation_times ?? 0, + top_candidates: payload?.top_candidates ?? [], + similarity_mode: + payload?.similarity_mode ?? detail?.result_summary?.similarity_mode ?? "-", + scheme_name: payload?.scheme_name ?? scheme.scheme_name, + username: payload?.username ?? scheme.username, + network: payload?.network ?? detail?.network, + data_source: payload?.data_source, + observed_source: payload?.observed_source ?? detail?.observed_source, + pressure_scada_ids: payload?.pressure_scada_ids ?? detail?.pressure_scada_ids, + flow_scada_ids: payload?.flow_scada_ids ?? detail?.flow_scada_ids, + create_time: payload?.create_time ?? scheme.create_time, + scada_window: payload?.scada_window ?? detail?.scada_window, + pressure_samples: payload?.pressure_samples, + flow_samples: payload?.flow_samples, + simulation_scheme: payload?.simulation_scheme, + }; + }; + const handleQuery = async () => { setLoading(true); try { @@ -47,7 +85,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { if (!queryAll && queryDate) { params.query_date = queryDate.startOf("day").toISOString(); } - + const response = await api.get(url, { params }); setSchemes(response.data); open?.({ @@ -73,10 +111,23 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { `${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`, { params: { network: NETWORK_NAME } }, ); - // The backend returns { scheme_detail: ... } inside the response or just the result? - // Based on burst_location.py: get_burst_location_scheme_detail returns the stored detail. - // Let's assume response.data is the BurstLocationResult - onViewResult(response.data as BurstLocationResult); + const schemeRecord = response.data as BurstSchemeRecord & { + result_payload?: BurstLocationResult; + }; + const normalizedResult = + schemeRecord.result_payload ?? + buildDisplayResult( + { + scheme_name: schemeRecord.scheme_name, + username: schemeRecord.username, + create_time: schemeRecord.create_time, + }, + schemeRecord.scheme_detail, + ); + if (!normalizedResult) { + throw new Error("方案详情缺少定位结果数据"); + } + onViewResult(normalizedResult); open?.({ type: "success", message: "方案加载成功", @@ -169,80 +220,118 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { <Typography variant="caption" className="text-gray-500 px-2"> 共 {schemes.length} 条记录 </Typography> - {schemes.map((scheme) => ( - <Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow"> - <CardContent className="p-3 pb-2 last:pb-3"> - <Box className="flex items-start justify-between gap-2 mb-2"> - <Box className="flex-1 min-w-0"> - <Box className="flex items-center gap-2 mb-1"> - <Typography variant="body2" className="font-medium truncate" title={scheme.scheme_name}> - {scheme.scheme_name} + {schemes.map((scheme) => { + const summary = scheme.scheme_detail?.result_summary; + const payload = scheme.scheme_detail?.result_payload; + const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-"; + const leakage = + payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage; + + return ( + <Card + key={scheme.scheme_id} + variant="outlined" + className="hover:shadow-md transition-shadow" + > + <CardContent className="p-3 pb-2 last:pb-3"> + <Box className="flex items-start justify-between gap-2 mb-2"> + <Box className="flex-1 min-w-0"> + <Box className="flex items-center gap-2 mb-1"> + <Typography + variant="body2" + className="font-medium truncate" + title={scheme.scheme_name} + > + {scheme.scheme_name} + </Typography> + <Chip + size="small" + variant="outlined" + color={ + payload?.data_source === "simulation" ? "secondary" : "primary" + } + label={ + payload?.data_source === "simulation" ? "模拟方案" : "监测数据" + } + className="h-5" + /> + </Box> + {payload?.data_source === "simulation" && + payload?.simulation_scheme?.name ? ( + <Typography + variant="caption" + className="mb-1 block truncate text-xs text-purple-600" + title={payload.simulation_scheme.name} + > + 方案: {payload.simulation_scheme.name} + </Typography> + ) : null} + <Typography variant="caption" className="block text-gray-500"> + ID: {scheme.scheme_id} · 日期:{" "} + {dayjs(scheme.create_time).format("MM-DD HH:mm")} </Typography> - <Chip size="small" variant="outlined" color="primary" label="爆管定位" className="h-5" /> </Box> - <Typography variant="caption" className="text-gray-500 block"> - ID: {scheme.scheme_id} · 日期: {dayjs(scheme.create_time).format("MM-DD HH:mm")} - </Typography> - </Box> - <Box className="flex gap-1 ml-2"> - <Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}> - <IconButton - size="small" - onClick={() => setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)} - color="primary" - className="p-1" - > - <InfoIcon fontSize="small" /> - </IconButton> - </Tooltip> - </Box> - </Box> - <Collapse in={expandedId === scheme.scheme_id}> - <Box className="mt-2 pt-3 border-t border-gray-200"> - <Box className="mb-3 rounded-md bg-gray-50 px-3 py-2 space-y-2"> - {/* Summary details */} - <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> - <Typography variant="caption" className="text-gray-600"> - 定位管段: - </Typography> - <Typography variant="caption" className="font-medium text-gray-900"> - {scheme.scheme_detail?.located_pipe || "-"} - </Typography> - </Box> - <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> - <Typography variant="caption" className="text-gray-600"> - 漏损量: - </Typography> - <Typography variant="caption" className="font-medium text-gray-900"> - {scheme.scheme_detail?.burst_leakage ? `${scheme.scheme_detail.burst_leakage} L/s` : "-"} - </Typography> - </Box> - <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> - <Typography variant="caption" className="text-gray-600"> - 用户: - </Typography> - <Typography variant="caption" className="font-medium text-gray-900"> - {scheme.username || "-"} - </Typography> - </Box> - </Box> - <Box className="pt-2 border-t border-gray-100"> - <Button - variant="contained" - fullWidth - size="small" - className="bg-blue-600 hover:bg-blue-700" - sx={{ textTransform: "none", fontWeight: 500 }} - onClick={() => handleViewSchemeResult(scheme.scheme_name)} - > - 查看定位结果 - </Button> + <Box className="flex gap-1 ml-2"> + <Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}> + <IconButton + size="small" + onClick={() => + setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id) + } + color="primary" + className="p-1" + > + <InfoIcon fontSize="small" /> + </IconButton> + </Tooltip> </Box> </Box> - </Collapse> - </CardContent> - </Card> - ))} + <Collapse in={expandedId === scheme.scheme_id}> + <Box className="mt-2 pt-3 border-t border-gray-200"> + <Box className="mb-3 rounded-md bg-gray-50 px-3 py-2 space-y-2"> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 定位管段: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {locatedPipe} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 漏损量: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {typeof leakage === "number" ? `${leakage} ${DMA_FLOW_DISPLAY_UNIT}` : "-"} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 用户: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {scheme.username || "-"} + </Typography> + </Box> + </Box> + <Box className="pt-2 border-t border-gray-100"> + <Button + variant="contained" + fullWidth + size="small" + className="bg-blue-600 hover:bg-blue-700" + sx={{ textTransform: "none", fontWeight: 500 }} + onClick={() => handleViewSchemeResult(scheme.scheme_name)} + > + 查看定位结果 + </Button> + </Box> + </Box> + </Collapse> + </CardContent> + </Card> + ); + })} </Box> )} </Box> diff --git a/src/components/olmap/BurstLocation/types.ts b/src/components/olmap/BurstLocation/types.ts index da03b0a..0500a36 100644 --- a/src/components/olmap/BurstLocation/types.ts +++ b/src/components/olmap/BurstLocation/types.ts @@ -13,15 +13,63 @@ export interface BurstLocationResult { scheme_name?: string; username?: string; observed_source?: string; + network?: string; + data_source?: string; + min_dpressure?: number; + basic_pressure?: number; pressure_scada_ids?: string[]; flow_scada_ids?: string[]; create_time?: string; + scada_window?: { + burst_start?: string; + burst_end?: string; + normal_start?: string; + normal_end?: string; + }; + pressure_samples?: { + burst?: number; + normal?: number; + }; + flow_samples?: { + burst?: number; + normal?: number; + }; + simulation_scheme?: { + name?: string; + type?: string; + }; +} + +export interface BurstLocationSchemeDetail { + network?: string; + pressure_scada_ids?: string[]; + flow_scada_ids?: string[]; + observed_source?: string; + algorithm_params?: { + burst_leakage?: number; + min_dpressure?: number; + basic_pressure?: number; + }; + scada_window?: { + burst_start?: string; + burst_end?: string; + normal_start?: string; + normal_end?: string; + }; + result_summary?: { + located_pipe?: string; + simulation_times?: number; + similarity_mode?: string; + }; + result_payload?: BurstLocationResult; } export interface BurstSchemeRecord { scheme_id: number; scheme_name: string; + scheme_type?: string; create_time: string; + scheme_start_time?: string; username?: string; - scheme_detail?: BurstLocationResult; + scheme_detail?: BurstLocationSchemeDetail; } -- 2.54.0 From 6b68b7d081b9564c9294fea4e45a2387f1ff46d1 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 14:25:31 +0800 Subject: [PATCH 036/281] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=8F=82=E6=95=B0=EF=BC=8C=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=8F=82=E6=95=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BurstLocation/AnalysisParameters.tsx | 61 ++----------------- src/components/olmap/BurstLocation/types.ts | 4 -- 2 files changed, 4 insertions(+), 61 deletions(-) diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 96f2d60..88c5cd5 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import RefreshIcon from "@mui/icons-material/Refresh"; import { @@ -59,12 +59,6 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { const [burstEndTime, setBurstEndTime] = useState<Dayjs | null>( dayjs().subtract(5, "minute"), ); - const [normalStartTime, setNormalStartTime] = useState<Dayjs | null>( - dayjs().subtract(2, "hour"), - ); - const [normalEndTime, setNormalEndTime] = useState<Dayjs | null>( - dayjs().subtract(90, "minute"), - ); const [minDpressure, setMinDpressure] = useState<number>(2); const [basicPressure, setBasicPressure] = useState<number>(10); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -78,16 +72,8 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setBurstStartTime(start); setBurstEndTime(end); - setNormalStartTime(start); - setNormalEndTime(end); }, []); - useEffect(() => { - if (!isSimulationMode) return; - setNormalStartTime(burstStartTime); - setNormalEndTime(burstEndTime); - }, [burstEndTime, burstStartTime, isSimulationMode]); - const fetchSchemes = useCallback( async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { if (schemeLoading || (!force && schemes.length > 0)) return; @@ -152,35 +138,24 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { const isValid = useMemo(() => { if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false; - if (!burstStartTime || !burstEndTime || !normalStartTime || !normalEndTime) { + if (!burstStartTime || !burstEndTime) { return false; } if (dataSource === "simulation" && !selectedSchemeId) { return false; } - return ( - burstStartTime.isBefore(burstEndTime) && - normalStartTime.isBefore(normalEndTime) - ); + return burstStartTime.isBefore(burstEndTime); }, [ burstLeakage, burstStartTime, burstEndTime, - normalStartTime, - normalEndTime, dataSource, selectedSchemeId, ]); const handleRun = async () => { - if ( - !isValid || - !burstStartTime || - !burstEndTime || - !normalStartTime || - !normalEndTime - ) { + if (!isValid || !burstStartTime || !burstEndTime) { open?.({ type: "error", message: "请完善参数并确认时间范围合法" }); return; } @@ -210,8 +185,6 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { basic_pressure: basicPressure, scada_burst_start: burstStartTime.toISOString(), scada_burst_end: burstEndTime.toISOString(), - scada_normal_start: normalStartTime.toISOString(), - scada_normal_end: normalEndTime.toISOString(), use_scada_flow: enableFlow || undefined, simulation_scheme_name: selectedScheme?.scheme_name, simulation_scheme_type: selectedScheme?.scheme_type, @@ -347,32 +320,6 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { slotProps={{ textField: { size: "small", fullWidth: true } }} /> </Box> - <Box> - <Typography variant="subtitle2" className="mb-1 font-medium"> - 正常开始时间 - </Typography> - <DateTimePicker - value={normalStartTime} - onChange={setNormalStartTime} - maxDateTime={normalEndTime ?? undefined} - disabled={isSimulationMode} - format="YYYY-MM-DD HH:mm" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - </Box> - <Box> - <Typography variant="subtitle2" className="mb-1 font-medium"> - 正常结束时间 - </Typography> - <DateTimePicker - value={normalEndTime} - onChange={setNormalEndTime} - minDateTime={normalStartTime ?? undefined} - disabled={isSimulationMode} - format="YYYY-MM-DD HH:mm" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - </Box> </Box> </LocalizationProvider> diff --git a/src/components/olmap/BurstLocation/types.ts b/src/components/olmap/BurstLocation/types.ts index 0500a36..8c2ffae 100644 --- a/src/components/olmap/BurstLocation/types.ts +++ b/src/components/olmap/BurstLocation/types.ts @@ -23,8 +23,6 @@ export interface BurstLocationResult { scada_window?: { burst_start?: string; burst_end?: string; - normal_start?: string; - normal_end?: string; }; pressure_samples?: { burst?: number; @@ -53,8 +51,6 @@ export interface BurstLocationSchemeDetail { scada_window?: { burst_start?: string; burst_end?: string; - normal_start?: string; - normal_end?: string; }; result_summary?: { located_pipe?: string; -- 2.54.0 From ddb02cc688e7682cfa5347ce08b0aea3e29b7b84 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 17:21:01 +0800 Subject: [PATCH 037/281] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B5=81=E9=87=8F?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E4=B8=BA=20m=C2=B3/h=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/Toolbar.tsx | 5 +- .../BurstLocation/AnalysisParameters.tsx | 6 +-- .../olmap/BurstLocation/LocationResults.tsx | 51 ++++++++++++++----- .../olmap/BurstLocation/SchemeQuery.tsx | 4 +- .../BurstPipeAnalysis/LocationResults.tsx | 3 +- .../DMALeakDetection/AnalysisParameters.tsx | 8 +-- .../DMALeakDetection/RecognitionResults.tsx | 8 +-- .../olmap/DMALeakDetection/SchemeQuery.tsx | 6 +-- .../olmap/DMALeakDetection/utils.ts | 2 +- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 3 +- 10 files changed, 61 insertions(+), 35 deletions(-) diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 7a763e2..361b137 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -21,6 +21,7 @@ import { useNotification } from "@refinedev/core"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { FLOW_DISPLAY_UNIT } from "@components/olmap/DMALeakDetection/utils"; // 添加接口定义隐藏按钮的props interface ToolbarProps { @@ -427,7 +428,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ const properties = highlightFeature.getProperties(); // 计算属性字段,增加 key 字段 const pipeComputedFields = [ - { key: "flow", label: "流量", unit: "m³/h" }, + { key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` }, { key: "friction", label: "摩阻", unit: "" }, { key: "headloss", label: "水头损失", unit: "m" }, { key: "unit_headloss", label: "单位水头损失", unit: "m/km" }, @@ -438,7 +439,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ { key: "velocity", label: "流速", unit: "m/s" }, ]; const nodeComputedFields = [ - { key: "actual_demand", label: "实际需水量", unit: "m³/h" }, + { key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` }, { key: "total_head", label: "水头", unit: "m" }, { key: "pressure", label: "压力", unit: "m" }, { key: "quality", label: "水质", unit: "mg/L" }, diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 88c5cd5..bf29a84 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -24,7 +24,7 @@ import dayjs, { Dayjs } from "dayjs"; import "dayjs/locale/zh-cn"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; -import { DMA_FLOW_DISPLAY_UNIT, toM3s } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT, toM3s } from "../DMALeakDetection/utils"; import { BurstLocationResult } from "./types"; interface Props { @@ -180,7 +180,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { network: NETWORK_NAME, data_source: dataSource, scheme_name: schemeName.trim() || undefined, - burst_leakage: toM3s(burstLeakage, DMA_FLOW_DISPLAY_UNIT), + burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT), min_dpressure: minDpressure, basic_pressure: basicPressure, scada_burst_start: burstStartTime.toISOString(), @@ -325,7 +325,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <Box className="flex flex-col gap-2"> <Typography variant="subtitle2" className="mb-1 font-medium"> - 总漏损流量 ({DMA_FLOW_DISPLAY_UNIT}) + 爆管漏损流量 ({FLOW_DISPLAY_UNIT}) </Typography> <TextField type="number" diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index 35a983d..fc3eda1 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -6,6 +6,7 @@ import { Typography, Chip, IconButton, + Tooltip, Table, TableBody, TableCell, @@ -28,7 +29,7 @@ import VectorSource from "ol/source/Vector"; import { Stroke, Style, Circle, Fill } from "ol/style"; import { bbox, featureCollection } from "@turf/turf"; import { BurstCandidate, BurstLocationResult } from "./types"; -import { DMA_FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface Props { result: BurstLocationResult | null; @@ -127,6 +128,14 @@ const LocationResults: React.FC<Props> = ({ result }) => { return base; }, [result]); + const allCandidatePipeIds = useMemo<string[]>(() => { + const ids = candidatePipes.map((item) => item.pipe_id); + if (result?.located_pipe) { + ids.unshift(result.located_pipe); + } + return Array.from(new Set(ids.filter(Boolean))); + }, [candidatePipes, result?.located_pipe]); + useEffect(() => { if (!map) return; @@ -263,7 +272,7 @@ const LocationResults: React.FC<Props> = ({ result }) => { /> <MetricCard label="估计漏损量" - value={`${result.burst_leakage.toFixed(2)} ${DMA_FLOW_DISPLAY_UNIT}`} + value={`${result.burst_leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`} tone="orange" /> <MetricCard @@ -288,18 +297,32 @@ const LocationResults: React.FC<Props> = ({ result }) => { 候选管段列表 </Typography> </Box> - <Chip - size="small" - label={`${candidatePipes.length} 条`} - sx={{ - height: 22, - backgroundColor: "rgba(37, 99, 235, 0.08)", - color: "#2563eb", - fontWeight: 600, - fontSize: "0.75rem", - border: "none", - }} - /> + <Box className="flex items-center gap-1"> + <Chip + size="small" + label={`${candidatePipes.length} 条`} + sx={{ + height: 22, + backgroundColor: "rgba(37, 99, 235, 0.08)", + color: "#2563eb", + fontWeight: 600, + fontSize: "0.75rem", + border: "none", + }} + /> + <Tooltip title="定位所有管段"> + <span> + <IconButton + size="small" + onClick={() => locatePipes(allCandidatePipeIds)} + disabled={allCandidatePipeIds.length === 0} + className="text-blue-600 hover:bg-blue-50 disabled:text-gray-300" + > + <LocationOnIcon fontSize="small" /> + </IconButton> + </span> + </Tooltip> + </Box> </Box> <Table size="small"> <TableHead> diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index 4aa7296..823d269 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -28,7 +28,7 @@ import { BurstLocationSchemeDetail, BurstSchemeRecord, } from "./types"; -import { DMA_FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface Props { onViewResult: (result: BurstLocationResult) => void; @@ -302,7 +302,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { 漏损量: </Typography> <Typography variant="caption" className="font-medium text-gray-900"> - {typeof leakage === "number" ? `${leakage} ${DMA_FLOW_DISPLAY_UNIT}` : "-"} + {typeof leakage === "number" ? `${leakage} ${FLOW_DISPLAY_UNIT}` : "-"} </Typography> </Box> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> diff --git a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx index e3c2dc7..d119b4e 100644 --- a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx +++ b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx @@ -32,6 +32,7 @@ import { toLonLat } from "ol/proj"; import moment from "moment"; import "moment-timezone"; import { LocationResult } from "./types"; +import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface LocationResultsProps { results?: LocationResult[]; @@ -306,7 +307,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({ sx={{ fontSize: "0.875rem" }} > {result.leakage !== null - ? `${result.leakage.toFixed(2)} m³/h` + ? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}` : "N/A"} </Typography> </Box> diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 4a6aa7a..80b2e20 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -20,7 +20,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { LeakageResultDetail } from "./types"; -import { DMA_FLOW_DISPLAY_UNIT, toM3s } from "./utils"; +import { FLOW_DISPLAY_UNIT, toM3s } from "./utils"; interface Props { onResult: (result: LeakageResultDetail) => void; @@ -68,9 +68,9 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { scada_end: endTime.toISOString(), pop_size: popSize, max_gen: maxGen, - q_sum: toM3s(qSum, DMA_FLOW_DISPLAY_UNIT), + q_sum: toM3s(qSum, FLOW_DISPLAY_UNIT), q_sum_unit: "m3/s", - output_flow_unit: DMA_FLOW_DISPLAY_UNIT, + output_flow_unit: FLOW_DISPLAY_UNIT, }, ); onResult(response.data as LeakageResultDetail); @@ -172,7 +172,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <Box className="flex flex-col gap-2"> <Typography variant="subtitle2" className="mb-1 font-medium"> - 总漏损流量 ({DMA_FLOW_DISPLAY_UNIT}) + 总漏损流量 ({FLOW_DISPLAY_UNIT}) </Typography> <TextField type="number" diff --git a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx index 601e6f1..5fa626b 100644 --- a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx +++ b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx @@ -13,7 +13,7 @@ import { } from "@mui/material"; import { FormatListBulleted } from "@mui/icons-material"; import dayjs from "dayjs"; -import { DMA_FLOW_DISPLAY_UNIT, getAreaColor, toM3h } from "./utils"; +import { FLOW_DISPLAY_UNIT, getAreaColor, toM3h } from "./utils"; import { LeakageResultDetail } from "./types"; interface Props { @@ -131,7 +131,7 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { ); const qSumM3h = toM3h(Number(val), unit); return Number.isFinite(qSumM3h) - ? `${qSumM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` + ? `${qSumM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}` : "-"; })()} </Typography> @@ -167,7 +167,7 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { ?.max_leakage; const maxLeakageM3h = toM3h(Number(maxL), "m3/s"); return Number.isFinite(maxLeakageM3h) - ? `${maxLeakageM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` + ? `${maxLeakageM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}` : "-"; })()} </Typography> @@ -215,7 +215,7 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { align="right" sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }} > - 漏损量 ({DMA_FLOW_DISPLAY_UNIT}) + 漏损量 ({FLOW_DISPLAY_UNIT}) </TableCell> </TableRow> </TableHead> diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index 70c403e..b3d7eaa 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -24,7 +24,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; -import { DMA_FLOW_DISPLAY_UNIT, toM3h } from "./utils"; +import { FLOW_DISPLAY_UNIT, toM3h } from "./utils"; interface Props { onViewResult: (result: LeakageResultDetail) => void; @@ -210,7 +210,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { const value = Number((scheme.scheme_detail as any)?.result_summary?.max_leakage); const maxLeakageM3h = toM3h(value, "m3/s"); return Number.isFinite(maxLeakageM3h) - ? `${maxLeakageM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` + ? `${maxLeakageM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}` : "-"; })()} </Typography> @@ -227,7 +227,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { ); const qSumM3h = toM3h(value, unit); return Number.isFinite(qSumM3h) - ? `${qSumM3h.toFixed(3)} ${DMA_FLOW_DISPLAY_UNIT}` + ? `${qSumM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}` : "-"; })()} </Typography> diff --git a/src/components/olmap/DMALeakDetection/utils.ts b/src/components/olmap/DMALeakDetection/utils.ts index 2d56f73..e2590e3 100644 --- a/src/components/olmap/DMALeakDetection/utils.ts +++ b/src/components/olmap/DMALeakDetection/utils.ts @@ -11,7 +11,7 @@ export const AREA_COLORS = [ "#be123c", ]; -export const DMA_FLOW_DISPLAY_UNIT = "m³/h"; +export const FLOW_DISPLAY_UNIT = "m³/h"; const M3H_FACTOR = 3600; export const getAreaColor = (areaId: string | number | undefined) => { diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index fa51752..3bd4f0c 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -40,6 +40,7 @@ import Feature, { FeatureLike } from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; import Timeline from "@app/OlMap/Controls/Timeline"; import { SchemeRecord, SchemaItem } from "./types"; +import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface SchemeQueryProps { schemes?: SchemeRecord[]; @@ -496,7 +497,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ 冲洗流量: </Typography> <Typography variant="caption" className="font-medium text-gray-900"> - {scheme.schemeDetail?.flushing_flow ?? "-"} m³/h + {scheme.schemeDetail?.flushing_flow ?? "-"} {FLOW_DISPLAY_UNIT} </Typography> </Box> -- 2.54.0 From b4ab3e287bddbdd1cb226d68af2672b07194aae6 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 17:31:14 +0800 Subject: [PATCH 038/281] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=B7=AF=E5=BE=84=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/Toolbar.tsx | 2 +- .../BurstLocation/AnalysisParameters.tsx | 2 +- .../olmap/BurstLocation/LocationResults.tsx | 2 +- .../olmap/BurstLocation/SchemeQuery.tsx | 2 +- .../BurstPipeAnalysis/LocationResults.tsx | 2 +- .../DMALeakDetection/AnalysisParameters.tsx | 2 +- .../DMALeakDetection/RecognitionResults.tsx | 3 +- .../olmap/DMALeakDetection/SchemeQuery.tsx | 2 +- .../olmap/DMALeakDetection/utils.ts | 17 ----------- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 2 +- src/utils/units.ts | 29 +++++++++++++++++++ 11 files changed, 39 insertions(+), 26 deletions(-) create mode 100644 src/utils/units.ts diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 361b137..affaa34 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -21,7 +21,7 @@ import { useNotification } from "@refinedev/core"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; -import { FLOW_DISPLAY_UNIT } from "@components/olmap/DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT } from "@utils/units"; // 添加接口定义隐藏按钮的props interface ToolbarProps { diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index bf29a84..b082362 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -24,7 +24,7 @@ import dayjs, { Dayjs } from "dayjs"; import "dayjs/locale/zh-cn"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; -import { FLOW_DISPLAY_UNIT, toM3s } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { BurstLocationResult } from "./types"; interface Props { diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index fc3eda1..4c2f747 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -29,7 +29,7 @@ import VectorSource from "ol/source/Vector"; import { Stroke, Style, Circle, Fill } from "ol/style"; import { bbox, featureCollection } from "@turf/turf"; import { BurstCandidate, BurstLocationResult } from "./types"; -import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT } from "@utils/units"; interface Props { result: BurstLocationResult | null; diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index 823d269..141fe44 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -28,7 +28,7 @@ import { BurstLocationSchemeDetail, BurstSchemeRecord, } from "./types"; -import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT } from "@utils/units"; interface Props { onViewResult: (result: BurstLocationResult) => void; diff --git a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx index d119b4e..84f05f8 100644 --- a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx +++ b/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx @@ -32,7 +32,7 @@ import { toLonLat } from "ol/proj"; import moment from "moment"; import "moment-timezone"; import { LocationResult } from "./types"; -import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT } from "@utils/units"; interface LocationResultsProps { results?: LocationResult[]; diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 80b2e20..4a796e3 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -20,7 +20,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { LeakageResultDetail } from "./types"; -import { FLOW_DISPLAY_UNIT, toM3s } from "./utils"; +import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; interface Props { onResult: (result: LeakageResultDetail) => void; diff --git a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx index 5fa626b..dce5e08 100644 --- a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx +++ b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx @@ -13,7 +13,8 @@ import { } from "@mui/material"; import { FormatListBulleted } from "@mui/icons-material"; import dayjs from "dayjs"; -import { FLOW_DISPLAY_UNIT, getAreaColor, toM3h } from "./utils"; +import { getAreaColor } from "./utils"; +import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { LeakageResultDetail } from "./types"; interface Props { diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index b3d7eaa..38fb60b 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -24,7 +24,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; -import { FLOW_DISPLAY_UNIT, toM3h } from "./utils"; +import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; interface Props { onViewResult: (result: LeakageResultDetail) => void; diff --git a/src/components/olmap/DMALeakDetection/utils.ts b/src/components/olmap/DMALeakDetection/utils.ts index e2590e3..bd46411 100644 --- a/src/components/olmap/DMALeakDetection/utils.ts +++ b/src/components/olmap/DMALeakDetection/utils.ts @@ -11,9 +11,6 @@ export const AREA_COLORS = [ "#be123c", ]; -export const FLOW_DISPLAY_UNIT = "m³/h"; -const M3H_FACTOR = 3600; - export const getAreaColor = (areaId: string | number | undefined) => { const text = String(areaId ?? ""); let hash = 0; @@ -22,17 +19,3 @@ export const getAreaColor = (areaId: string | number | undefined) => { } return AREA_COLORS[hash % AREA_COLORS.length]; }; - -export const toM3h = (value: number, sourceUnit: string = "m³/s") => { - if (!Number.isFinite(value)) return Number.NaN; - const normalizedUnit = sourceUnit.trim().toLowerCase(); - if (normalizedUnit === "m³/h") return value; - return value * M3H_FACTOR; -}; - -export const toM3s = (value: number, sourceUnit: string = "m³/h") => { - if (!Number.isFinite(value)) return Number.NaN; - const normalizedUnit = sourceUnit.trim().toLowerCase(); - if (normalizedUnit === "m³/s") return value; - return value / M3H_FACTOR; -}; diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index 3bd4f0c..2d2d7f2 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -40,7 +40,7 @@ import Feature, { FeatureLike } from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; import Timeline from "@app/OlMap/Controls/Timeline"; import { SchemeRecord, SchemaItem } from "./types"; -import { FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; +import { FLOW_DISPLAY_UNIT } from "@utils/units"; interface SchemeQueryProps { schemes?: SchemeRecord[]; diff --git a/src/utils/units.ts b/src/utils/units.ts new file mode 100644 index 0000000..08dcf7e --- /dev/null +++ b/src/utils/units.ts @@ -0,0 +1,29 @@ +export const FLOW_DISPLAY_UNIT = "m³/h"; +const M3H_FACTOR = 3600; + +export const toM3h = (value: number, sourceUnit: string = "m³/s") => { + if (!Number.isFinite(value)) return Number.NaN; + const normalizedUnit = sourceUnit.trim().toLowerCase(); + if (normalizedUnit === "m³/h") return value; + if (normalizedUnit === "lps" || normalizedUnit === "l/s") return value * 3.6; + if (normalizedUnit === "m³/s") return value * M3H_FACTOR; + return value * M3H_FACTOR; +}; + +export const toM3s = (value: number, sourceUnit: string = "m³/h") => { + if (!Number.isFinite(value)) return Number.NaN; + const normalizedUnit = sourceUnit.trim().toLowerCase(); + if (normalizedUnit === "m³/s") return value; + if (normalizedUnit === "lps" || normalizedUnit === "l/s") return value / 1000; + if (normalizedUnit === "m³/h") return value / M3H_FACTOR; + return value / M3H_FACTOR; +}; + +export const toLps = (value: number, sourceUnit: string = "m³/s") => { + if (!Number.isFinite(value)) return Number.NaN; + const normalizedUnit = sourceUnit.trim().toLowerCase(); + if (normalizedUnit === "lps" || normalizedUnit === "l/s") return value; + if (normalizedUnit === "m³/h") return value / 3.6; + if (normalizedUnit === "m³/s") return value * 1000; + return value * 1000; +}; -- 2.54.0 From 47e47fc6054578e067fff6d7b558fd4bb9616485 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 17:49:14 +0800 Subject: [PATCH 039/281] =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E9=9C=80=E6=B0=B4=E9=87=8F=E5=8D=95=E4=BD=8D=E4=B8=BA=20m?= =?UTF-8?q?=C2=B3/h=EF=BC=8C=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/Toolbar.tsx | 16 ++++++++++--- src/app/OlMap/MapComponent.tsx | 36 ++++++++++++------------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index affaa34..14afb0f 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -21,7 +21,7 @@ import { useNotification } from "@refinedev/core"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; -import { FLOW_DISPLAY_UNIT } from "@utils/units"; +import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; // 添加接口定义隐藏按钮的props interface ToolbarProps { @@ -467,6 +467,11 @@ const Toolbar: React.FC<ToolbarProps> = ({ if (computedProperties) { pipeComputedFields.forEach(({ key, label, unit }) => { let value = computedProperties[key]; + + if (key === "flow" && value !== undefined) { + value = toM3h(value, "lps"); + } + // 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km) if ( key === "unit_headloss" && @@ -505,10 +510,11 @@ const Toolbar: React.FC<ToolbarProps> = ({ columns: ["demand", "pattern"], rows: Array.from({ length: 5 }, (_, i) => i + 1) .map((idx) => { - const d = properties?.[`demand${idx}`]?.toFixed?.(3); + let d = properties?.[`demand${idx}`]; const p = properties?.[`pattern${idx}`]; // 仅当 demand 有效时展示该行 if (d !== undefined && d !== null && d !== "") { + d = toM3h(Number(d), "lps"); return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"]; } }) @@ -520,10 +526,14 @@ const Toolbar: React.FC<ToolbarProps> = ({ if (computedProperties) { nodeComputedFields.forEach(({ key, label, unit }) => { if (computedProperties[key] !== undefined) { + let value = computedProperties[key]; + if (key === "actual_demand") { + value = toM3h(value, "lps"); + } result.properties.push({ label, value: - computedProperties[key].toFixed?.(3) || computedProperties[key], + value?.toFixed?.(3) || value, unit, }); } diff --git a/src/app/OlMap/MapComponent.tsx b/src/app/OlMap/MapComponent.tsx index 99c053d..d319678 100644 --- a/src/app/OlMap/MapComponent.tsx +++ b/src/app/OlMap/MapComponent.tsx @@ -33,6 +33,7 @@ import { Icon, Style } from "ol/style.js"; import { FeatureLike } from "ol/Feature"; import { Point } from "ol/geom"; import { ContourLayer } from "deck.gl"; +import { toM3h } from "@utils/units"; interface MapComponentProps { children?: React.ReactNode; @@ -151,7 +152,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r])); return junctionData.map((j) => { const record = nodeMap.get(j.id); - return record ? { ...j, [junctionText]: record.value } : j; + let val = record ? record.value : undefined; + // 在这合并时将实际需水量从 LPS 转换为大写表示 + if (val !== undefined && junctionText === "actualdemand") { + val = toM3h(val, "lps"); + } + return record ? { ...j, [junctionText]: val } : j; }); }, [junctionData, currentJunctionCalData, junctionText]); @@ -161,9 +167,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const record = linkMap.get(p.id); if (!record) return p; const isFlow = pipeText === "flow"; + let val = record.value; + if (val !== undefined && isFlow) { + val = toM3h(val, "lps"); + } return { ...p, - [pipeText]: isFlow ? Math.abs(record.value) : record.value, + [pipeText]: isFlow ? Math.abs(val) : val, flowFlag: isFlow && record.value < 0 ? -1 : 1, path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, }; @@ -790,15 +800,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { let propPart = ""; if (showJunctionTextLayer && d[junctionText] !== undefined) { const value = (d[junctionText] as number).toFixed(3); - // 根据属性类型添加符号前缀 - const prefix = - { - pressure: "P:", - head: "H:", - quality: "Q:", - actualdemand: "D:", - }[junctionText] || ""; - propPart = `${prefix}${value}`; + propPart = `${value}`; } if (idPart && propPart) return `${idPart} - ${propPart}`; return idPart || propPart; @@ -850,17 +852,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { } else { value = Math.abs(d[pipeText] as number).toFixed(3); } - // 根据属性类型添加符号前缀 - const prefix = - { - flow: "F:", - velocity: "V:", - headloss: "HL:", - unit_headloss: "UHL:", - diameter: "D:", - friction: "FR:", - }[pipeText] || ""; - propPart = `${prefix}${value}`; + propPart = `${value}`; } if (idPart && propPart) return `${idPart} - ${propPart}`; return idPart || propPart; -- 2.54.0 From 7f25bd34d5b4792483ac6387408734323c834b38 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Sat, 7 Mar 2026 19:56:35 +0800 Subject: [PATCH 040/281] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E7=9A=84=E6=95=B0=E6=8D=AE=E8=BD=AC=E6=8D=A2=E6=BC=8F=E6=8D=9F?= =?UTF-8?q?=E9=87=8F=E5=8D=95=E4=BD=8D=E4=B8=BA=20m=C2=B3/h=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/BurstLocation/LocationResults.tsx | 4 ++-- src/components/olmap/BurstLocation/SchemeQuery.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index 4c2f747..f344e86 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -29,7 +29,7 @@ import VectorSource from "ol/source/Vector"; import { Stroke, Style, Circle, Fill } from "ol/style"; import { bbox, featureCollection } from "@turf/turf"; import { BurstCandidate, BurstLocationResult } from "./types"; -import { FLOW_DISPLAY_UNIT } from "@utils/units"; +import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; interface Props { result: BurstLocationResult | null; @@ -272,7 +272,7 @@ const LocationResults: React.FC<Props> = ({ result }) => { /> <MetricCard label="估计漏损量" - value={`${result.burst_leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`} + value={`${toM3h(result.burst_leakage, "m³/s").toFixed(2)} ${FLOW_DISPLAY_UNIT}`} tone="orange" /> <MetricCard diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index 141fe44..0b28bc7 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -28,7 +28,7 @@ import { BurstLocationSchemeDetail, BurstSchemeRecord, } from "./types"; -import { FLOW_DISPLAY_UNIT } from "@utils/units"; +import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; interface Props { onViewResult: (result: BurstLocationResult) => void; @@ -302,7 +302,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { 漏损量: </Typography> <Typography variant="caption" className="font-medium text-gray-900"> - {typeof leakage === "number" ? `${leakage} ${FLOW_DISPLAY_UNIT}` : "-"} + {typeof leakage === "number" ? `${toM3h(leakage, "m³/s")} ${FLOW_DISPLAY_UNIT}` : "-"} </Typography> </Box> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> -- 2.54.0 From 520e1cb3f1fe5cecaba6f869ae3c34bc47d7bb84 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Tue, 10 Mar 2026 11:04:30 +0800 Subject: [PATCH 041/281] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package_back.json | 58 ------ src/app/(main)/health-risk-analysis/page.tsx | 6 +- .../burst-location/page.tsx | 4 +- .../loading.tsx | 0 .../page.tsx | 6 +- .../loading.tsx | 0 .../page.tsx | 4 +- .../dma-leak-detection/page.tsx | 4 +- .../loading.tsx | 0 .../page.tsx | 4 +- .../monitoring-place-optimization/page.tsx | 4 +- .../network-partition-optimization/page.tsx | 4 +- src/app/(main)/network-simulation/page.tsx | 10 +- src/app/(main)/scada-data-cleaning/page.tsx | 8 +- src/app/_refine_context.tsx | 6 +- .../olmap/BurstLocation/LocationResults.tsx | 2 +- .../AnalysisParameters.tsx | 2 +- .../BurstPipeAnalysisPanel.tsx | 0 .../LocationResults.tsx | 2 +- .../SchemeQuery.tsx | 4 +- .../ValveIsolation.tsx | 2 +- .../types.ts | 0 .../AnalysisParameters.tsx | 2 +- .../ContaminantSimulation/SchemeQuery.tsx | 4 +- .../WaterQualityPanel.tsx | 2 +- .../DMALeakDetectionPanel.tsx | 4 +- .../FlushingAnalysis/AnalysisParameters.tsx | 2 +- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 4 +- .../olmap/HealthRiskAnalysis/Timeline.tsx | 4 +- .../SchemeQuery.tsx | 2 +- .../ZonePropsPanel.tsx | 2 +- .../olmap/{ => SCADA}/SCADADataPanel.tsx | 0 .../olmap/{ => SCADA}/SCADADeviceList.tsx | 2 +- .../olmap/core}/Controls/BaseLayers.tsx | 0 .../olmap/core}/Controls/DrawPanel.tsx | 0 .../olmap/core}/Controls/HistoryDataPanel.tsx | 0 .../olmap/core}/Controls/LayerControl.tsx | 0 .../olmap/core}/Controls/PropertyPanel.tsx | 0 .../olmap/core}/Controls/ScaleLine.tsx | 0 .../olmap/core}/Controls/StyleEditorPanel.tsx | 0 .../olmap/core}/Controls/StyleLegend.tsx | 0 .../olmap/core}/Controls/Timeline.tsx | 0 .../olmap/core}/Controls/Toolbar.tsx | 0 .../olmap/core}/Controls/Zoom.tsx | 0 .../olmap/core}/MapComponent.tsx | 0 .../olmap/core}/MapTools.tsx | 0 src/utils/breaks_classification.js | 181 ------------------ src/utils/breaks_classification.ts | 136 +++++++++++++ src/utils/parseColor.js | 35 ---- src/utils/parseColor.test.js | 21 -- src/utils/parseColor.test.ts | 25 +++ src/utils/parseColor.ts | 31 +++ 52 files changed, 242 insertions(+), 345 deletions(-) delete mode 100644 package_back.json rename src/app/(main)/hydraulic-simulation/{pipe-burst-analysis => burst-simulation}/loading.tsx (100%) rename src/app/(main)/hydraulic-simulation/{pipe-burst-analysis => burst-simulation}/page.tsx (55%) rename src/app/(main)/hydraulic-simulation/{pipe-flushing => contaminant-simulation}/loading.tsx (100%) rename src/app/(main)/hydraulic-simulation/{water-quality-simulation => contaminant-simulation}/page.tsx (74%) rename src/app/(main)/hydraulic-simulation/{water-quality-simulation => flushing-analysis}/loading.tsx (100%) rename src/app/(main)/hydraulic-simulation/{pipe-flushing => flushing-analysis}/page.tsx (74%) rename src/components/olmap/{BurstPipeAnalysis => BurstSimulation}/AnalysisParameters.tsx (99%) rename src/components/olmap/{BurstPipeAnalysis => BurstSimulation}/BurstPipeAnalysisPanel.tsx (100%) rename src/components/olmap/{BurstPipeAnalysis => BurstSimulation}/LocationResults.tsx (99%) rename src/components/olmap/{BurstPipeAnalysis => BurstSimulation}/SchemeQuery.tsx (99%) rename src/components/olmap/{BurstPipeAnalysis => BurstSimulation}/ValveIsolation.tsx (99%) rename src/components/olmap/{BurstPipeAnalysis => BurstSimulation}/types.ts (100%) rename src/components/olmap/{ => SCADA}/SCADADataPanel.tsx (100%) rename src/components/olmap/{ => SCADA}/SCADADeviceList.tsx (99%) rename src/{app/OlMap => components/olmap/core}/Controls/BaseLayers.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/DrawPanel.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/HistoryDataPanel.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/LayerControl.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/PropertyPanel.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/ScaleLine.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/StyleEditorPanel.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/StyleLegend.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/Timeline.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/Toolbar.tsx (100%) rename src/{app/OlMap => components/olmap/core}/Controls/Zoom.tsx (100%) rename src/{app/OlMap => components/olmap/core}/MapComponent.tsx (100%) rename src/{app/OlMap => components/olmap/core}/MapTools.tsx (100%) delete mode 100644 src/utils/breaks_classification.js create mode 100644 src/utils/breaks_classification.ts delete mode 100644 src/utils/parseColor.js delete mode 100644 src/utils/parseColor.test.js create mode 100644 src/utils/parseColor.test.ts create mode 100644 src/utils/parseColor.ts diff --git a/package_back.json b/package_back.json deleted file mode 100644 index 0a8c876..0000000 --- a/package_back.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "tjwater-app", - "version": "0.1.0", - "private": true, - "engines": { - "node": ">=20" - }, - "scripts": { - "dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev", - "build": "refine build", - "start": "refine start", - "lint": "next lint", - "refine": "refine" - }, - "dependencies": { - "@emotion/react": "^11.8.2", - "@emotion/styled": "^11.8.1", - "@mui/icons-material": "^6.1.6", - "@mui/lab": "^6.0.0-beta.14", - "@mui/material": "^6.1.7", - "@mui/x-data-grid": "^7.22.2", - "@refinedev/cli": "^2.16.48", - "@refinedev/core": "^5.0.0", - "@refinedev/devtools": "^2.0.1", - "@refinedev/kbar": "^2.0.0", - "@refinedev/mui": "^7.0.0", - "@refinedev/nextjs-router": "^7.0.0", - "@refinedev/react-hook-form": "^5.0.0", - "@refinedev/simple-rest": "^6.0.0", - "@tailwindcss/postcss": "^4.1.13", - "@turf/turf": "^7.2.0", - "clsx": "^2.1.1", - "deck.gl": "^9.1.14", - "js-cookie": "^3.0.5", - "next": "^15.2.4", - "next-auth": "^4.24.5", - "ol": "^10.6.1", - "postcss": "^8.5.6", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-icons": "^5.5.0", - "tailwindcss": "^4.1.13" - }, - "devDependencies": { - "@svgr/webpack": "^8.1.0", - "@types/js-cookie": "^3.0.6", - "@types/node": "^20", - "@types/react": "^19.1.0", - "@types/react-dom": "^19.1.0", - "cross-env": "^7.0.3", - "eslint": "^8", - "eslint-config-next": "^15.0.3", - "typescript": "^5.8.3" - }, - "refine": { - "projectId": "4LwOCL-BBaV29-qUYMAJ" - } -} diff --git a/src/app/(main)/health-risk-analysis/page.tsx b/src/app/(main)/health-risk-analysis/page.tsx index 67881f7..f6c014d 100644 --- a/src/app/(main)/health-risk-analysis/page.tsx +++ b/src/app/(main)/health-risk-analysis/page.tsx @@ -1,12 +1,12 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; +import MapComponent from "@components/olmap/core/MapComponent"; import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext"; import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"; import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel"; -import StyleLegend from "@app/OlMap/Controls/StyleLegend"; +import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; import { RAINBOW_COLORS, RISK_BREAKS, diff --git a/src/app/(main)/hydraulic-simulation/burst-location/page.tsx b/src/app/(main)/hydraulic-simulation/burst-location/page.tsx index f8e01cd..977ef8e 100644 --- a/src/app/(main)/hydraulic-simulation/burst-location/page.tsx +++ b/src/app/(main)/hydraulic-simulation/burst-location/page.tsx @@ -1,7 +1,7 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel"; export default function Home() { diff --git a/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/loading.tsx b/src/app/(main)/hydraulic-simulation/burst-simulation/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/pipe-burst-analysis/loading.tsx rename to src/app/(main)/hydraulic-simulation/burst-simulation/loading.tsx diff --git a/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx b/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx similarity index 55% rename from src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx rename to src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx index 0942860..8078cf7 100644 --- a/src/app/(main)/hydraulic-simulation/pipe-burst-analysis/page.tsx +++ b/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx @@ -1,8 +1,8 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; -import BurstPipeAnalysisPanel from "@/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"; export default function Home() { return ( diff --git a/src/app/(main)/hydraulic-simulation/pipe-flushing/loading.tsx b/src/app/(main)/hydraulic-simulation/contaminant-simulation/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/pipe-flushing/loading.tsx rename to src/app/(main)/hydraulic-simulation/contaminant-simulation/loading.tsx diff --git a/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx similarity index 74% rename from src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx rename to src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx index 6929b05..2c3d499 100644 --- a/src/app/(main)/hydraulic-simulation/water-quality-simulation/page.tsx +++ b/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx @@ -1,7 +1,7 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel"; export default function Home() { diff --git a/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx b/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx index fc90a12..68fc86a 100644 --- a/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx +++ b/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx @@ -1,7 +1,7 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"; export default function Home() { diff --git a/src/app/(main)/hydraulic-simulation/water-quality-simulation/loading.tsx b/src/app/(main)/hydraulic-simulation/flushing-analysis/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/water-quality-simulation/loading.tsx rename to src/app/(main)/hydraulic-simulation/flushing-analysis/loading.tsx diff --git a/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx b/src/app/(main)/hydraulic-simulation/flushing-analysis/page.tsx similarity index 74% rename from src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx rename to src/app/(main)/hydraulic-simulation/flushing-analysis/page.tsx index a0dcf6e..05e45cb 100644 --- a/src/app/(main)/hydraulic-simulation/pipe-flushing/page.tsx +++ b/src/app/(main)/hydraulic-simulation/flushing-analysis/page.tsx @@ -1,7 +1,7 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"; export default function Home() { diff --git a/src/app/(main)/monitoring-place-optimization/page.tsx b/src/app/(main)/monitoring-place-optimization/page.tsx index ef51c2c..8ce7202 100644 --- a/src/app/(main)/monitoring-place-optimization/page.tsx +++ b/src/app/(main)/monitoring-place-optimization/page.tsx @@ -1,7 +1,7 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"; export default function Home() { return ( diff --git a/src/app/(main)/network-partition-optimization/page.tsx b/src/app/(main)/network-partition-optimization/page.tsx index ddf0a80..07aa7aa 100644 --- a/src/app/(main)/network-partition-optimization/page.tsx +++ b/src/app/(main)/network-partition-optimization/page.tsx @@ -1,7 +1,7 @@ "use client"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import ZonePropsPanel from "@components/olmap/NetworkPartitionOptimization/ZonePropsPanel"; export default function Home() { return ( diff --git a/src/app/(main)/network-simulation/page.tsx b/src/app/(main)/network-simulation/page.tsx index d454bea..18ec7c7 100644 --- a/src/app/(main)/network-simulation/page.tsx +++ b/src/app/(main)/network-simulation/page.tsx @@ -1,12 +1,12 @@ "use client"; import { useCallback, useState } from "react"; -import MapComponent from "@app/OlMap/MapComponent"; -import Timeline from "@app/OlMap/Controls/Timeline"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import Timeline from "@components/olmap/core/Controls/Timeline"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import SCADADeviceList from "@components/olmap/SCADADeviceList"; -import SCADADataPanel from "@components/olmap/SCADADataPanel"; +import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList"; +import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel"; export default function Home() { const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]); diff --git a/src/app/(main)/scada-data-cleaning/page.tsx b/src/app/(main)/scada-data-cleaning/page.tsx index 2258ee8..8458924 100644 --- a/src/app/(main)/scada-data-cleaning/page.tsx +++ b/src/app/(main)/scada-data-cleaning/page.tsx @@ -1,11 +1,11 @@ "use client"; import { useCallback, useState } from "react"; -import MapComponent from "@app/OlMap/MapComponent"; -import MapToolbar from "@app/OlMap/Controls/Toolbar"; +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import SCADADeviceList from "@components/olmap/SCADADeviceList"; -import SCADADataPanel from "@components/olmap/SCADADataPanel"; +import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList"; +import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel"; export default function Home() { const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]); diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index 4d75a3b..ecd54d9 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -177,7 +177,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => { }, { name: "爆管模拟", - list: "/hydraulic-simulation/pipe-burst-analysis", + list: "/hydraulic-simulation/burst-simulation", meta: { parent: "Hydraulic Simulation", icon: <TbLocationPin className="w-6 h-6" />, @@ -204,7 +204,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => { }, { name: "水质模拟", - list: "/hydraulic-simulation/water-quality-simulation", + list: "/hydraulic-simulation/contaminant-simulation", meta: { parent: "Hydraulic Simulation", icon: <MdOutlineWaterDrop className="w-6 h-6" />, @@ -213,7 +213,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => { }, { name: "管道冲洗", - list: "/hydraulic-simulation/pipe-flushing", + list: "/hydraulic-simulation/flushing-analysis", meta: { parent: "Hydraulic Simulation", icon: <MdCleaningServices className="w-6 h-6" />, diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index f344e86..d000c99 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -20,7 +20,7 @@ import { Map as MapIcon, } from "@mui/icons-material"; import dayjs from "dayjs"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { GeoJSON } from "ol/format"; import Feature from "ol/Feature"; diff --git a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx similarity index 99% rename from src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx rename to src/components/olmap/BurstSimulation/AnalysisParameters.tsx index 7e242c0..5482313 100644 --- a/src/components/olmap/BurstPipeAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx @@ -16,7 +16,7 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import "dayjs/locale/zh-cn"; // 引入中文包 import dayjs, { Dayjs } from "dayjs"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Style, Stroke, Icon } from "ol/style"; diff --git a/src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx similarity index 100% rename from src/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel.tsx rename to src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx diff --git a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx b/src/components/olmap/BurstSimulation/LocationResults.tsx similarity index 99% rename from src/components/olmap/BurstPipeAnalysis/LocationResults.tsx rename to src/components/olmap/BurstSimulation/LocationResults.tsx index 84f05f8..6f5efcf 100644 --- a/src/components/olmap/BurstPipeAnalysis/LocationResults.tsx +++ b/src/components/olmap/BurstSimulation/LocationResults.tsx @@ -13,7 +13,7 @@ import { LocationOn as LocationIcon, } from "@mui/icons-material"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; diff --git a/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx similarity index 99% rename from src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx rename to src/components/olmap/BurstSimulation/SchemeQuery.tsx index b1c5e6b..6256d16 100644 --- a/src/components/olmap/BurstPipeAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -32,7 +32,7 @@ import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; -import { useData, useMap } from "@app/OlMap/MapComponent"; +import { useData, useMap } from "@components/olmap/core/MapComponent"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; @@ -48,7 +48,7 @@ import { } from "@turf/turf"; import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; -import Timeline from "@app/OlMap/Controls/Timeline"; +import Timeline from "@components/olmap/core/Controls/Timeline"; import { SchemaItem, SchemeRecord } from "./types"; interface SchemeQueryProps { diff --git a/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx similarity index 99% rename from src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx rename to src/components/olmap/BurstSimulation/ValveIsolation.tsx index 7553bbc..def9331 100644 --- a/src/components/olmap/BurstPipeAnalysis/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -35,7 +35,7 @@ import { queryFeaturesByIds, handleMapClickSelectFeatures, } from "@/utils/mapQueryService"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; diff --git a/src/components/olmap/BurstPipeAnalysis/types.ts b/src/components/olmap/BurstSimulation/types.ts similarity index 100% rename from src/components/olmap/BurstPipeAnalysis/types.ts rename to src/components/olmap/BurstSimulation/types.ts diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index ee58be3..8303e65 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -19,7 +19,7 @@ import dayjs, { Dayjs } from "dayjs"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style"; diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index 21d29fd..ccef88d 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -30,14 +30,14 @@ import moment from "moment"; import { useNotification } from "@refinedev/core"; import { config, NETWORK_NAME } from "@config/config"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; -import { useData, useMap } from "@app/OlMap/MapComponent"; +import { useData, useMap } from "@components/olmap/core/MapComponent"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Style, Icon, Circle, Fill, Stroke } from "ol/style"; import Feature from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; -import Timeline from "@app/OlMap/Controls/Timeline"; +import Timeline from "@components/olmap/core/Controls/Timeline"; import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types"; interface SchemeQueryProps { diff --git a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx index 43c7023..d7ba9e4 100644 --- a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx +++ b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx @@ -19,7 +19,7 @@ import { } from "@mui/icons-material"; import ContaminantAnalysisParameters from "./AnalysisParameters"; import ContaminantSchemeQuery from "./SchemeQuery"; -import { useData } from "@app/OlMap/MapComponent"; +import { useData } from "@components/olmap/core/MapComponent"; interface WaterQualityPanelProps { open?: boolean; diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index 24b9e4c..9dd5fd3 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -21,8 +21,8 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import VectorTileSource from "ol/source/VectorTile"; import { VectorTile } from "ol"; import { FlatStyleLike } from "ol/style/flat"; -import { useMap } from "@app/OlMap/MapComponent"; -import StyleLegend from "@app/OlMap/Controls/StyleLegend"; +import { useMap } from "@components/olmap/core/MapComponent"; +import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; import RecognitionResults from "./RecognitionResults"; diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 32e2f8f..40dbb9f 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -18,7 +18,7 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import "dayjs/locale/zh-cn"; import dayjs, { Dayjs } from "dayjs"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Style, Stroke, Fill, Circle as CircleStyle } from "ol/style"; diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index 2d2d7f2..b37a35a 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -30,7 +30,7 @@ import { api } from "@/lib/api"; import moment from "moment"; import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; -import { useData, useMap } from "@app/OlMap/MapComponent"; +import { useData, useMap } from "@components/olmap/core/MapComponent"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; @@ -38,7 +38,7 @@ import VectorSource from "ol/source/Vector"; import { Style, Icon, Circle, Fill, Stroke } from "ol/style"; import Feature, { FeatureLike } from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; -import Timeline from "@app/OlMap/Controls/Timeline"; +import Timeline from "@components/olmap/core/Controls/Timeline"; import { SchemeRecord, SchemaItem } from "./types"; import { FLOW_DISPLAY_UNIT } from "@utils/units"; diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index 13ff443..8c4a561 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -27,10 +27,10 @@ import dayjs from "dayjs"; import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material"; import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb"; import { FiSkipBack, FiSkipForward } from "react-icons/fi"; -import { useData } from "../../../app/OlMap/MapComponent"; +import { useData } from "@components/olmap/core/MapComponent"; import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; -import { useMap } from "../../../app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import { useHealthRisk } from "./HealthRiskContext"; import { PredictionResult, diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 25560d0..23198b4 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -28,7 +28,7 @@ import { api } from "@/lib/api"; import moment from "moment"; import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; diff --git a/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx b/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx index 6052313..32d43ab 100644 --- a/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx +++ b/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx @@ -6,7 +6,7 @@ import Fill from "ol/style/Fill"; import { Stroke } from "ol/style"; import GeoJson from "ol/format/GeoJSON"; import config from "@config/config"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import { useProject } from "@/contexts/ProjectContext"; interface PropertyItem { diff --git a/src/components/olmap/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx similarity index 100% rename from src/components/olmap/SCADADataPanel.tsx rename to src/components/olmap/SCADA/SCADADataPanel.tsx diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADA/SCADADeviceList.tsx similarity index 99% rename from src/components/olmap/SCADADeviceList.tsx rename to src/components/olmap/SCADA/SCADADeviceList.tsx index c8fdd1e..9626b1c 100644 --- a/src/components/olmap/SCADADeviceList.tsx +++ b/src/components/olmap/SCADA/SCADADeviceList.tsx @@ -52,7 +52,7 @@ import { api } from "@/lib/api"; import { useGetIdentity } from "@refinedev/core"; import config from "@/config/config"; -import { useMap } from "@app/OlMap/MapComponent"; +import { useMap } from "@components/olmap/core/MapComponent"; import { useProject } from "@/contexts/ProjectContext"; import { GeoJSON } from "ol/format"; import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; diff --git a/src/app/OlMap/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx similarity index 100% rename from src/app/OlMap/Controls/BaseLayers.tsx rename to src/components/olmap/core/Controls/BaseLayers.tsx diff --git a/src/app/OlMap/Controls/DrawPanel.tsx b/src/components/olmap/core/Controls/DrawPanel.tsx similarity index 100% rename from src/app/OlMap/Controls/DrawPanel.tsx rename to src/components/olmap/core/Controls/DrawPanel.tsx diff --git a/src/app/OlMap/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx similarity index 100% rename from src/app/OlMap/Controls/HistoryDataPanel.tsx rename to src/components/olmap/core/Controls/HistoryDataPanel.tsx diff --git a/src/app/OlMap/Controls/LayerControl.tsx b/src/components/olmap/core/Controls/LayerControl.tsx similarity index 100% rename from src/app/OlMap/Controls/LayerControl.tsx rename to src/components/olmap/core/Controls/LayerControl.tsx diff --git a/src/app/OlMap/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx similarity index 100% rename from src/app/OlMap/Controls/PropertyPanel.tsx rename to src/components/olmap/core/Controls/PropertyPanel.tsx diff --git a/src/app/OlMap/Controls/ScaleLine.tsx b/src/components/olmap/core/Controls/ScaleLine.tsx similarity index 100% rename from src/app/OlMap/Controls/ScaleLine.tsx rename to src/components/olmap/core/Controls/ScaleLine.tsx diff --git a/src/app/OlMap/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx similarity index 100% rename from src/app/OlMap/Controls/StyleEditorPanel.tsx rename to src/components/olmap/core/Controls/StyleEditorPanel.tsx diff --git a/src/app/OlMap/Controls/StyleLegend.tsx b/src/components/olmap/core/Controls/StyleLegend.tsx similarity index 100% rename from src/app/OlMap/Controls/StyleLegend.tsx rename to src/components/olmap/core/Controls/StyleLegend.tsx diff --git a/src/app/OlMap/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx similarity index 100% rename from src/app/OlMap/Controls/Timeline.tsx rename to src/components/olmap/core/Controls/Timeline.tsx diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx similarity index 100% rename from src/app/OlMap/Controls/Toolbar.tsx rename to src/components/olmap/core/Controls/Toolbar.tsx diff --git a/src/app/OlMap/Controls/Zoom.tsx b/src/components/olmap/core/Controls/Zoom.tsx similarity index 100% rename from src/app/OlMap/Controls/Zoom.tsx rename to src/components/olmap/core/Controls/Zoom.tsx diff --git a/src/app/OlMap/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx similarity index 100% rename from src/app/OlMap/MapComponent.tsx rename to src/components/olmap/core/MapComponent.tsx diff --git a/src/app/OlMap/MapTools.tsx b/src/components/olmap/core/MapTools.tsx similarity index 100% rename from src/app/OlMap/MapTools.tsx rename to src/components/olmap/core/MapTools.tsx diff --git a/src/utils/breaks_classification.js b/src/utils/breaks_classification.js deleted file mode 100644 index 5be3974..0000000 --- a/src/utils/breaks_classification.js +++ /dev/null @@ -1,181 +0,0 @@ -/** - * 优雅分段分类 - 类似QGIS的Pretty Breaks - * 生成"好看"、易读的断点数值 - * @param {number[]} data - 数据数组 - * @param {number} n_classes - 分类数量 - * @returns {number[]} 断点数组 - */ -function prettyBreaksClassification(data, n_classes) { - if (data.length === 0) return []; - - // const min_val = Math.min(...data); - // const max_val = Math.max(...data); - // const min_val = data.reduce((min, val) => Math.min(min, val), Infinity); - // 保证最小值不小于0 - const min_val = Math.max(data.reduce((min, val) => Math.min(min, val), Infinity), 0); - const max_val = data.reduce((max, val) => Math.max(max, val), -Infinity); - const data_range = max_val - min_val; - - // 计算基础间隔 - const raw_interval = data_range / n_classes; - - // 寻找"优雅"的间隔 - const magnitude = 10 ** Math.floor(Math.log10(raw_interval)); - const normalized = raw_interval / magnitude; - - // 选择最接近的优雅数字 - let nice_interval; - if (normalized <= 1) { - nice_interval = magnitude; - } else if (normalized <= 2) { - nice_interval = 2 * magnitude; - } else if (normalized <= 5) { - nice_interval = 5 * magnitude; - } else { - nice_interval = 10 * magnitude; - } - - // 计算优雅的起始点 - const nice_min = Math.floor(min_val / nice_interval) * nice_interval; - const nice_max = Math.ceil(max_val / nice_interval) * nice_interval; - - // 生成断点 - const breaks = []; - let current = nice_min; - while (current <= nice_max && breaks.length < n_classes + 1) { - breaks.push(current); - current += nice_interval; - } - - // 确保包含最大值 - if (breaks.length === 0 || breaks[breaks.length - 1] < max_val) { - breaks.push(nice_max); - } - - // 调整为n_classes个区间 - if (breaks.length > n_classes + 1) { - breaks.splice(n_classes + 1); - } - - return breaks; -} - -/** - * 计算类内方差 - * @param {number[]} data - 排序后的数据数组 - * @param {number} start - 起始索引 - * @param {number} end - 结束索引 - * @returns {number} 类内方差 - */ -function variance(data, start, end) { - if (start >= end) return 0; - const mean = data.slice(start, end + 1).reduce((a, b) => a + b, 0) / (end - start + 1); - return data.slice(start, end + 1).reduce((sum, val) => sum + (val - mean) ** 2, 0); -} - -/** - * Jenks自然断点分类算法 - * @param {number[]} data - 数据数组 - * @param {number} n_classes - 分类数量 - * @returns {number[]} 断点数组 - */ -function jenks_breaks_jenkspy(data, n_classes) { - if (data.length === 0) return []; - if (n_classes >= data.length) return data.slice().sort((a, b) => a - b); - - const sortedData = data.slice().sort((a, b) => a - b); - const n = sortedData.length; - const k = n_classes; - - // 初始化矩阵 - const lowerClassLimits = Array.from({ length: n + 1 }, () => Array(k + 1).fill(0)); - const varianceCombinations = Array.from({ length: n + 1 }, () => Array(k + 1).fill(0)); - - for (let i = 1; i <= n; i++) { - lowerClassLimits[i][1] = 1; - varianceCombinations[i][1] = variance(sortedData, 0, i - 1); - for (let j = 2; j <= k; j++) { - varianceCombinations[i][j] = Infinity; - } - } - - // 动态规划 - for (let l = 2; l <= k; l++) { - for (let m = l; m <= n; m++) { - for (let i = l - 1; i < m; i++) { - const v = varianceCombinations[i][l - 1] + variance(sortedData, i, m - 1); - if (v < varianceCombinations[m][l]) { - varianceCombinations[m][l] = v; - lowerClassLimits[m][l] = i; - } - } - } - } - - // 回溯找到断点 - const breaks = []; - let current = n; - for (let j = k; j >= 1; j--) { - breaks.unshift(sortedData[lowerClassLimits[current][j] - 1] || sortedData[0]); - current = lowerClassLimits[current][j]; - } - breaks.push(sortedData[n - 1]); - - return breaks; -} - -/** - * 使用分层采样优化的Jenks算法 - * 确保采样数据能代表原数据的分布 - * @param {number[]} data - 数据数组 - * @param {number} n_classes - 分类数量 - * @param {number} sample_size - 采样大小,默认10000 - * @returns {number[]} 断点数组 - */ -function jenks_with_stratified_sampling(data, n_classes, sample_size = 10000) { - if (data.length <= sample_size) { - return jenks_breaks_jenkspy(data, n_classes); - } - - // 对数据排序 - const sorted_data = data.slice().sort((a, b) => a - b); - - // 计算采样间隔 - const interval = sorted_data.length / sample_size; - - // 分层采样 - const sampled_data = []; - for (let i = 0; i < sample_size; i++) { - const index = Math.floor(i * interval); - if (index < sorted_data.length) { - sampled_data.push(sorted_data[index]); - } - } - - return jenks_breaks_jenkspy(sampled_data, n_classes); -} - -/** - * 根据指定的方法计算数据的分类断点。 - * @param {Array<number>} data - 要分类的数值数据数组。 - * @param {number} segments - 要创建的段数或类别数。 - * @param {string} classificationMethod - 要使用的分类方法。支持的值:"pretty_breaks" 或 "jenks_optimized"。 - * @returns {Array<number>} 分类的断点数组。如果数据为空或无效,则返回空数组。 - */ -function calculateClassification( - data, - segments, - classificationMethod -) { - if (!data || data.length === 0) { - return []; - } - if (classificationMethod === "pretty_breaks") { - return prettyBreaksClassification(data, segments); - } - if (classificationMethod === "jenks_optimized") { - return jenks_with_stratified_sampling(data, segments); - } -} - -module.exports = { prettyBreaksClassification, jenks_breaks_jenkspy, jenks_with_stratified_sampling, calculateClassification }; \ No newline at end of file diff --git a/src/utils/breaks_classification.ts b/src/utils/breaks_classification.ts new file mode 100644 index 0000000..ba98b6e --- /dev/null +++ b/src/utils/breaks_classification.ts @@ -0,0 +1,136 @@ +export function prettyBreaksClassification( + data: number[], + nClasses: number +): number[] { + if (data.length === 0) return []; + + const minVal = Math.max(data.reduce((min, val) => Math.min(min, val), Infinity), 0); + const maxVal = data.reduce((max, val) => Math.max(max, val), -Infinity); + const dataRange = maxVal - minVal; + const rawInterval = dataRange / nClasses; + + const magnitude = 10 ** Math.floor(Math.log10(rawInterval)); + const normalized = rawInterval / magnitude; + + let niceInterval: number; + if (normalized <= 1) { + niceInterval = magnitude; + } else if (normalized <= 2) { + niceInterval = 2 * magnitude; + } else if (normalized <= 5) { + niceInterval = 5 * magnitude; + } else { + niceInterval = 10 * magnitude; + } + + const niceMin = Math.floor(minVal / niceInterval) * niceInterval; + const niceMax = Math.ceil(maxVal / niceInterval) * niceInterval; + + const breaks: number[] = []; + let current = niceMin; + while (current <= niceMax && breaks.length < nClasses + 1) { + breaks.push(current); + current += niceInterval; + } + + if (breaks.length === 0 || breaks[breaks.length - 1] < maxVal) { + breaks.push(niceMax); + } + + if (breaks.length > nClasses + 1) { + breaks.splice(nClasses + 1); + } + + return breaks; +} + +function variance(data: number[], start: number, end: number): number { + if (start >= end) return 0; + const mean = data.slice(start, end + 1).reduce((a, b) => a + b, 0) / (end - start + 1); + return data.slice(start, end + 1).reduce((sum, val) => sum + (val - mean) ** 2, 0); +} + +export function jenks_breaks_jenkspy(data: number[], nClasses: number): number[] { + if (data.length === 0) return []; + if (nClasses >= data.length) return data.slice().sort((a, b) => a - b); + + const sortedData = data.slice().sort((a, b) => a - b); + const n = sortedData.length; + const k = nClasses; + + const lowerClassLimits = Array.from({ length: n + 1 }, () => Array(k + 1).fill(0)); + const varianceCombinations = Array.from({ length: n + 1 }, () => Array(k + 1).fill(0)); + + for (let i = 1; i <= n; i++) { + lowerClassLimits[i][1] = 1; + varianceCombinations[i][1] = variance(sortedData, 0, i - 1); + for (let j = 2; j <= k; j++) { + varianceCombinations[i][j] = Infinity; + } + } + + for (let l = 2; l <= k; l++) { + for (let m = l; m <= n; m++) { + for (let i = l - 1; i < m; i++) { + const v = varianceCombinations[i][l - 1] + variance(sortedData, i, m - 1); + if (v < varianceCombinations[m][l]) { + varianceCombinations[m][l] = v; + lowerClassLimits[m][l] = i; + } + } + } + } + + const breaks: number[] = []; + let current = n; + for (let j = k; j >= 1; j--) { + breaks.unshift(sortedData[lowerClassLimits[current][j] - 1] || sortedData[0]); + current = lowerClassLimits[current][j]; + } + breaks.push(sortedData[n - 1]); + + return breaks; +} + +export function jenks_with_stratified_sampling( + data: number[], + nClasses: number, + sampleSize = 10000 +): number[] { + if (data.length <= sampleSize) { + return jenks_breaks_jenkspy(data, nClasses); + } + + const sortedData = data.slice().sort((a, b) => a - b); + const interval = sortedData.length / sampleSize; + + const sampledData: number[] = []; + for (let i = 0; i < sampleSize; i++) { + const index = Math.floor(i * interval); + if (index < sortedData.length) { + sampledData.push(sortedData[index]); + } + } + + return jenks_breaks_jenkspy(sampledData, nClasses); +} + +export function calculateClassification( + data: number[], + segments: number, + classificationMethod: string +): number[] { + if (!data || data.length === 0) { + return []; + } + + if (classificationMethod === "pretty_breaks") { + return prettyBreaksClassification(data, segments); + } + + if (classificationMethod === "jenks_optimized") { + return jenks_with_stratified_sampling(data, segments); + } + + return []; +} diff --git a/src/utils/parseColor.js b/src/utils/parseColor.js deleted file mode 100644 index 89b0206..0000000 --- a/src/utils/parseColor.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 将颜色字符串解析为包含红色、绿色、蓝色和 alpha 分量的对象。 - * 支持 rgba、rgb 和十六进制颜色格式。对于 rgba 和 rgb,提取 r、g、b 和 a(如果未提供,则默认为 1)。 - * 对于十六进制(例如 #RRGGBB),提取 r、g、b。(e.g., "rgba(255, 0, 0, 0.5)", "rgb(255, 0, 0)", or "#FF0000"). - * @param {string} color - 要解析的颜色字符串(例如 "rgba(255, 0, 0, 0.5)"、"rgb(255, 0, 0)" 或 "#FF0000")。 - * @returns {{r: number, g: number, b: number, a?: number}} 包含颜色分量的对象: - * - r: 红色分量 (0-255) - * - g: 绿色分量 (0-255) - * - b: 蓝色分量 (0-255) - * - a: Alpha 分量 (0-1),如果未指定则默认为 1 - **/ -function parseColor(color) { - // 解析 rgba 格式的颜色 - const match = color.match( - /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/ - ); - if (match) { - return { - r: parseInt(match[1], 10), - g: parseInt(match[2], 10), - b: parseInt(match[3], 10), - // 如果没有 alpha 值,默认为 1 - a: match[4] ? parseFloat(match[4]) : 1, - }; - } - // 如果还是十六进制格式,保持原来的解析方式 - const hex = color.replace("#", ""); - return { - r: parseInt(hex.slice(0, 2), 16), - g: parseInt(hex.slice(2, 4), 16), - b: parseInt(hex.slice(4, 6), 16), - }; -} - -module.exports = { parseColor }; \ No newline at end of file diff --git a/src/utils/parseColor.test.js b/src/utils/parseColor.test.js deleted file mode 100644 index 3d95a87..0000000 --- a/src/utils/parseColor.test.js +++ /dev/null @@ -1,21 +0,0 @@ -const { parseColor } = require('./parseColor'); - -describe('parseColor', () => { - it('should parse hex color', () => { - expect(parseColor('#FF0000')).toEqual({ r: 255, g: 0, b: 0 }); - expect(parseColor('00FF00')).toEqual({ r: 0, g: 255, b: 0 }); - }); - - it('should parse rgb color', () => { - expect(parseColor('rgb(0, 0, 255)')).toEqual({ r: 0, g: 0, b: 255, a: 1 }); - }); - - it('should parse rgba color', () => { - expect(parseColor('rgba(0, 0, 255, 0.5)')).toEqual({ r: 0, g: 0, b: 255, a: 0.5 }); - }); - - it('should default alpha to 1 if not provided in rgba-like pattern', () => { - // The regex supports optional alpha - expect(parseColor('rgba(0, 0, 255)')).toEqual({ r: 0, g: 0, b: 255, a: 1 }); - }); -}); diff --git a/src/utils/parseColor.test.ts b/src/utils/parseColor.test.ts new file mode 100644 index 0000000..09b4b10 --- /dev/null +++ b/src/utils/parseColor.test.ts @@ -0,0 +1,25 @@ +import { parseColor } from "./parseColor"; + +describe("parseColor", () => { + it("should parse hex color", () => { + expect(parseColor("#FF0000")).toEqual({ r: 255, g: 0, b: 0 }); + expect(parseColor("00FF00")).toEqual({ r: 0, g: 255, b: 0 }); + }); + + it("should parse rgb color", () => { + expect(parseColor("rgb(0, 0, 255)")).toEqual({ r: 0, g: 0, b: 255, a: 1 }); + }); + + it("should parse rgba color", () => { + expect(parseColor("rgba(0, 0, 255, 0.5)")).toEqual({ + r: 0, + g: 0, + b: 255, + a: 0.5, + }); + }); + + it("should default alpha to 1 if not provided in rgba-like pattern", () => { + expect(parseColor("rgba(0, 0, 255)")).toEqual({ r: 0, g: 0, b: 255, a: 1 }); + }); +}); diff --git a/src/utils/parseColor.ts b/src/utils/parseColor.ts new file mode 100644 index 0000000..cecc343 --- /dev/null +++ b/src/utils/parseColor.ts @@ -0,0 +1,31 @@ +export interface ParsedColor { + r: number; + g: number; + b: number; + a?: number; +} + +/** + * 将颜色字符串解析为包含红色、绿色、蓝色和 alpha 分量的对象。 + */ +export function parseColor(color: string): ParsedColor { + const match = color.match( + /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/ + ); + + if (match) { + return { + r: parseInt(match[1], 10), + g: parseInt(match[2], 10), + b: parseInt(match[3], 10), + a: match[4] ? parseFloat(match[4]) : 1, + }; + } + + const hex = color.replace("#", ""); + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; +} -- 2.54.0 From 64dcf9cbdb57aa779fb911cabc89b1f2a0f808d9 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Tue, 10 Mar 2026 11:38:37 +0800 Subject: [PATCH 042/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20ESLint=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E4=BF=AE=E6=94=B9=20lint=20=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.json | 3 --- eslint.config.mjs | 5 +++++ package.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 eslint.config.mjs diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..63ade01 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,5 @@ +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; + +const config = [...nextCoreWebVitals]; + +export default config; \ No newline at end of file diff --git a/package.json b/package.json index a144984..e2c26ec 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev", "build": "refine build", "start": "refine start", - "lint": "next lint", + "lint": "eslint .", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", -- 2.54.0 From 62914f80c3c8defda819f69b681ffd430e7c26a4 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Tue, 10 Mar 2026 17:35:20 +0800 Subject: [PATCH 043/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20redo=20undo=20?= =?UTF-8?q?=E7=9A=84=E9=80=BB=E8=BE=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/core/Controls/DrawPanel.tsx | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/src/components/olmap/core/Controls/DrawPanel.tsx b/src/components/olmap/core/Controls/DrawPanel.tsx index 66a74f9..3980cc9 100644 --- a/src/components/olmap/core/Controls/DrawPanel.tsx +++ b/src/components/olmap/core/Controls/DrawPanel.tsx @@ -35,8 +35,13 @@ const DrawPanel: React.FC = () => { null ); const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]); - const [historyStack, setHistoryStack] = useState<Feature<Geometry>[][]>([]); - const [historyIndex, setHistoryIndex] = useState<number>(-1); + const [history, setHistory] = useState<{ + stack: Feature<Geometry>[][]; + index: number; + }>({ + stack: [[]], + index: 0, + }); const drawInteractionRef = useRef<Draw | null>(null); @@ -88,14 +93,16 @@ const DrawPanel: React.FC = () => { // 保存到历史记录 const saveToHistory = useCallback( (features: Feature<Geometry>[]) => { - setHistoryStack((prevStack) => { - const newHistory = prevStack.slice(0, historyIndex + 1); - newHistory.push([...features]); - setHistoryIndex(newHistory.length - 1); - return newHistory; + setHistory((prev) => { + const newStack = prev.stack.slice(0, prev.index + 1); + newStack.push([...features]); + return { + stack: newStack, + index: newStack.length - 1, + }; }); }, - [historyIndex] + [] ); // 添加绘图交互 @@ -153,7 +160,13 @@ const DrawPanel: React.FC = () => { // 绘图完成事件 draw.on("drawend", (event: DrawEvent) => { const feature = event.feature; - const currentFeatures = [...drawnFeatures, feature]; + const currentFeatures = [...source.getFeatures()]; + + // Fallback in case feature has not been synced to source yet. + if (!currentFeatures.includes(feature)) { + currentFeatures.push(feature); + } + setDrawnFeatures(currentFeatures); saveToHistory(currentFeatures); }); @@ -244,23 +257,29 @@ const DrawPanel: React.FC = () => { // 撤销功能 const handleUndo = () => { - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - const previousFeatures = historyStack[newIndex]; + if (history.index > 0) { + const newIndex = history.index - 1; + const previousFeatures = history.stack[newIndex]; updateDrawLayer(previousFeatures); setDrawnFeatures(previousFeatures); - setHistoryIndex(newIndex); + setHistory((prev) => ({ + ...prev, + index: newIndex, + })); } }; // 重做功能 const handleRedo = () => { - if (historyIndex < historyStack.length - 1) { - const newIndex = historyIndex + 1; - const nextFeatures = historyStack[newIndex]; + if (history.index < history.stack.length - 1) { + const newIndex = history.index + 1; + const nextFeatures = history.stack[newIndex]; updateDrawLayer(nextFeatures); setDrawnFeatures(nextFeatures); - setHistoryIndex(newIndex); + setHistory((prev) => ({ + ...prev, + index: newIndex, + })); } }; @@ -291,17 +310,9 @@ const DrawPanel: React.FC = () => { } }; - // 初始化历史记录 - useEffect(() => { - // 初始化空的历史记录 - if (historyStack.length === 0) { - saveToHistory([]); - } - }, [historyStack.length, saveToHistory]); - // 判断按钮是否应该禁用 - const isUndoDisabled = historyIndex <= 0; - const isRedoDisabled = historyIndex >= historyStack.length - 1; + const isUndoDisabled = history.index <= 0; + const isRedoDisabled = history.index >= history.stack.length - 1; const isDeleteDisabled = drawnFeatures.length === 0; const isSaveDisabled = drawnFeatures.length === 0; -- 2.54.0 From 73201ae44ea6ad90162ca25a5f28fec001c74c1f Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Tue, 10 Mar 2026 17:52:00 +0800 Subject: [PATCH 044/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dlint=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/BurstLocation/LocationResults.tsx | 15 +- .../olmap/BurstSimulation/LocationResults.tsx | 15 +- .../olmap/BurstSimulation/ValveIsolation.tsx | 2 +- .../olmap/HealthRiskAnalysis/Timeline.tsx | 36 +- .../olmap/core/Controls/DrawPanel.tsx | 10 +- .../olmap/core/Controls/LayerControl.tsx | 119 +++--- .../olmap/core/Controls/StyleEditorPanel.tsx | 390 ++++++++---------- .../olmap/core/Controls/Timeline.tsx | 40 +- .../olmap/core/Controls/Toolbar.tsx | 9 +- src/components/olmap/core/MapComponent.tsx | 63 ++- src/contexts/color-mode/index.tsx | 22 +- 11 files changed, 343 insertions(+), 378 deletions(-) diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index d000c99..afef844 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Typography, @@ -115,7 +115,7 @@ const EmptyState = () => ( const LocationResults: React.FC<Props> = ({ result }) => { const map = useMap(); - const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); + const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const candidatePipes = useMemo<BurstCandidate[]>(() => { @@ -128,13 +128,13 @@ const LocationResults: React.FC<Props> = ({ result }) => { return base; }, [result]); - const allCandidatePipeIds = useMemo<string[]>(() => { + const allCandidatePipeIds = (() => { const ids = candidatePipes.map((item) => item.pipe_id); if (result?.located_pipe) { ids.unshift(result.located_pipe); } return Array.from(new Set(ids.filter(Boolean))); - }, [candidatePipes, result?.located_pipe]); + })(); useEffect(() => { if (!map) return; @@ -159,19 +159,20 @@ const LocationResults: React.FC<Props> = ({ result }) => { }, }); map.addLayer(layer); - setHighlightLayer(layer); + highlightLayerRef.current = layer; return () => { + highlightLayerRef.current = null; map.removeLayer(layer); }; }, [map]); useEffect(() => { - const source = highlightLayer?.getSource(); + const source = highlightLayerRef.current?.getSource(); if (!source) return; source.clear(); highlightFeatures.forEach((feature) => source.addFeature(feature)); - }, [highlightFeatures, highlightLayer]); + }, [highlightFeatures]); const locatePipes = async (pipeIds: string[]) => { if (!pipeIds.length || !map) return; diff --git a/src/components/olmap/BurstSimulation/LocationResults.tsx b/src/components/olmap/BurstSimulation/LocationResults.tsx index 6f5efcf..d5b3503 100644 --- a/src/components/olmap/BurstSimulation/LocationResults.tsx +++ b/src/components/olmap/BurstSimulation/LocationResults.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Box, Typography, @@ -41,8 +41,7 @@ interface LocationResultsProps { const LocationResults: React.FC<LocationResultsProps> = ({ results = [], }) => { - const [highlightLayer, setHighlightLayer] = - useState<VectorLayer<VectorSource> | null>(null); + const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const map = useMap(); @@ -145,19 +144,17 @@ const LocationResults: React.FC<LocationResultsProps> = ({ }); map.addLayer(highlightLayer); - setHighlightLayer(highlightLayer); + highlightLayerRef.current = highlightLayer; return () => { + highlightLayerRef.current = null; map.removeLayer(highlightLayer); }; }, [map]); // 高亮要素的函数 useEffect(() => { - if (!highlightLayer) { - return; - } - const source = highlightLayer.getSource(); + const source = highlightLayerRef.current?.getSource(); if (!source) { return; } @@ -169,7 +166,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({ source.addFeature(feature); } }); - }, [highlightFeatures, highlightLayer]); + }, [highlightFeatures]); // 取第一条记录或空对象 const result = results.length > 0 ? results[0] : null; diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index def9331..b4985a8 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -1090,7 +1090,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ </> ) : ( <Alert severity="info" variant="outlined"> - 请先在流程2中选择不可用阀门,然后点击"扩大搜索"按钮 + 请先在流程2中选择不可用阀门,然后点击“扩大搜索”按钮 </Alert> )} </Box> diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index 8c4a561..a4f0bde 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useNotification } from "@refinedev/core"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; @@ -27,7 +27,6 @@ import dayjs from "dayjs"; import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material"; import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb"; import { FiSkipBack, FiSkipForward } from "react-icons/fi"; -import { useData } from "@components/olmap/core/MapComponent"; import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; import { useMap } from "@components/olmap/core/MapComponent"; @@ -63,10 +62,6 @@ interface TimelineProps { const Timeline: React.FC<TimelineProps> = ({ disableDateSelection = false, }) => { - const data = useData(); - if (!data) { - return <div>Loading...</div>; // 或其他占位符 - } const { open } = useNotification(); const { predictionResults, @@ -79,7 +74,6 @@ const Timeline: React.FC<TimelineProps> = ({ const [isPlaying, setIsPlaying] = useState<boolean>(false); const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒 const [isPredicting, setIsPredicting] = useState<boolean>(false); - const [pipeLayer, setPipeLayer] = useState<WebGLVectorTileLayer | null>(null); // 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定 const healthDataRef = useRef<Map<string, number>>(new Map()); @@ -228,10 +222,21 @@ const Timeline: React.FC<TimelineProps> = ({ clearTimeout(debounceRef.current); } }; - }, [pipeLayer]); + }, []); // 获取地图实例 const map = useMap(); + const pipeLayer = useMemo(() => { + if (!map) return null; + + const layers = map.getLayers().getArray(); + return ( + layers.find( + (layer) => + layer instanceof WebGLVectorTileLayer && layer.get("value") === "pipes", + ) as WebGLVectorTileLayer | undefined + ) ?? null; + }, [map]); // 根据索引从 survival_function 中获取生存概率 const getSurvivalProbabilityAtYear = useCallback( @@ -362,21 +367,6 @@ const Timeline: React.FC<TimelineProps> = ({ updatePipeHealthData, ]); - // 初始化管道图层 - useEffect(() => { - if (!map) return; - - const layers = map.getLayers().getArray(); - const pipesLayer = layers.find( - (layer) => - layer instanceof WebGLVectorTileLayer && layer.get("value") === "pipes", - ) as WebGLVectorTileLayer | undefined; - - if (pipesLayer) { - setPipeLayer(pipesLayer); - } - }, [map]); - // 监听依赖变化,更新样式 useEffect(() => { if (predictionResults.length > 0 && pipeLayer) { diff --git a/src/components/olmap/core/Controls/DrawPanel.tsx b/src/components/olmap/core/Controls/DrawPanel.tsx index 3980cc9..448f09f 100644 --- a/src/components/olmap/core/Controls/DrawPanel.tsx +++ b/src/components/olmap/core/Controls/DrawPanel.tsx @@ -31,9 +31,7 @@ import { useMap } from "../MapComponent"; const DrawPanel: React.FC = () => { const map = useMap(); const [activeTool, setActiveTool] = useState<string>("pan"); - const [drawLayer, setDrawLayer] = useState<VectorLayer<VectorSource> | null>( - null - ); + const drawLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]); const [history, setHistory] = useState<{ stack: Feature<Geometry>[][]; @@ -79,13 +77,14 @@ const DrawPanel: React.FC = () => { }); map.addLayer(drawVectorLayer); - setDrawLayer(drawVectorLayer); + drawLayerRef.current = drawVectorLayer; return () => { if (drawInteractionRef.current && map) { map.removeInteraction(drawInteractionRef.current); drawInteractionRef.current = null; } + drawLayerRef.current = null; map.removeLayer(drawVectorLayer); }; }, [map, drawInteractionRef]); @@ -110,6 +109,7 @@ const DrawPanel: React.FC = () => { type: GeometryType, geometryFunction?: GeometryFunction ) => { + const drawLayer = drawLayerRef.current; if (!drawLayer) return; if (!map) return; @@ -285,6 +285,7 @@ const DrawPanel: React.FC = () => { // 删除所有绘制的要素 const handleDelete = () => { + const drawLayer = drawLayerRef.current; if (!drawLayer) return; const source = drawLayer.getSource(); @@ -301,6 +302,7 @@ const DrawPanel: React.FC = () => { // 更新绘图图层 const updateDrawLayer = (features: Feature<Geometry>[]) => { + const drawLayer = drawLayerRef.current; if (!drawLayer) return; const source = drawLayer.getSource(); diff --git a/src/components/olmap/core/Controls/LayerControl.tsx b/src/components/olmap/core/Controls/LayerControl.tsx index 8b539a5..d71fe10 100644 --- a/src/components/olmap/core/Controls/LayerControl.tsx +++ b/src/components/olmap/core/Controls/LayerControl.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { useData, useMap } from "../MapComponent"; import { Checkbox, FormControlLabel } from "@mui/material"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; @@ -18,15 +18,12 @@ interface LayerItem { const LayerControl: React.FC = () => { const map = useMap(); const data = useData(); - if (!data) return; - const { - deckLayer, - isContourLayerAvailable, - isWaterflowLayerAvailable, - setShowWaterflowLayer, - setShowContourLayer, - } = data; - const [layerItems, setLayerItems] = useState<LayerItem[]>([]); + const [refreshKey, setRefreshKey] = useState(0); + const deckLayer = data?.deckLayer; + const isContourLayerAvailable = data?.isContourLayerAvailable; + const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable; + const setShowWaterflowLayer = data?.setShowWaterflowLayer; + const setShowContourLayer = data?.setShowContourLayer; const layerOrder = [ "junctions", @@ -40,16 +37,12 @@ const LayerControl: React.FC = () => { "junctionContourLayer", ]; - // 更新图层列表 - const updateLayers = useCallback(() => { - if (!map || !data) return; + const layerItems = useMemo(() => { + if (!map || !data) return []; const items: LayerItem[] = []; - // 1. 获取 OpenLayers 图层 - const mapLayers = map.getLayers().getArray(); - mapLayers.forEach((layer) => { - // 筛选特定类型的 OpenLayers 图层 + map.getLayers().getArray().forEach((layer) => { if ( layer instanceof WebGLVectorTileLayer || layer instanceof VectorTileLayer || @@ -57,7 +50,6 @@ const LayerControl: React.FC = () => { ) { const value = layer.get("value"); const name = layer.get("name"); - // 只有设置了 value (作为 ID) 的图层才会被纳入控制 if (value) { items.push({ id: value, @@ -70,66 +62,56 @@ const LayerControl: React.FC = () => { } }); - // 2. 获取 DeckLayer 中的子图层 if (deckLayer && deckLayer instanceof DeckLayer) { - const deckLayers = deckLayer.getDeckLayers(); - deckLayers.forEach((layer: any) => { - if (layer && layer.id) { - // 仅处理 junctionContourLayer 和 waterflowLayer - if ( - layer.id !== "junctionContourLayer" && - layer.id !== "waterflowLayer" - ) { - return; - } - // 检查可用性 - if ( - (layer.id === "junctionContourLayer" && !isContourLayerAvailable) || - (layer.id === "waterflowLayer" && !isWaterflowLayerAvailable) - ) { - return; // 跳过不可用图层 - } - const visible = - deckLayer.getDeckLayerVisible(layer.id) ?? - layer.props?.visible ?? - true; - items.push({ - id: layer.props.id, - name: layer.props.name, // 使用 name 属性作为显示名称 - visible: visible, - type: "deck", - layerRef: layer, - }); + deckLayer.getDeckLayers().forEach((layer: any) => { + if (!layer?.id) return; + if (layer.id !== "junctionContourLayer" && layer.id !== "waterflowLayer") { + return; } + if ( + (layer.id === "junctionContourLayer" && !isContourLayerAvailable) || + (layer.id === "waterflowLayer" && !isWaterflowLayerAvailable) + ) { + return; + } + + items.push({ + id: layer.props.id, + name: layer.props.name, + visible: + deckLayer.getDeckLayerVisible(layer.id) ?? layer.props?.visible ?? true, + type: "deck", + layerRef: layer, + }); }); } - // 过滤并排序 - const sortedItems = items + return items .filter((item) => layerOrder.includes(item.id)) - .sort((a, b) => { - const indexA = layerOrder.indexOf(a.id); - const indexB = layerOrder.indexOf(b.id); - return indexA - indexB; - }); - - setLayerItems(sortedItems); - }, [map, deckLayer, isWaterflowLayerAvailable, isContourLayerAvailable]); + .sort((a, b) => layerOrder.indexOf(a.id) - layerOrder.indexOf(b.id)); + }, [ + map, + data, + deckLayer, + isContourLayerAvailable, + isWaterflowLayerAvailable, + refreshKey, + ]); useEffect(() => { - updateLayers(); + if (!map) return; - if (map) { - const layerCollection = map.getLayers(); - layerCollection.on("change:length", updateLayers); - } + const layerCollection = map.getLayers(); + const handleLayerChange = () => { + setRefreshKey((prev) => prev + 1); + }; + + layerCollection.on("change:length", handleLayerChange); return () => { - if (map) { - map.getLayers().un("change:length", updateLayers); - } + map.getLayers().un("change:length", handleLayerChange); }; - }, [map, updateLayers]); + }, [map]); const handleVisibilityChange = (item: LayerItem, checked: boolean) => { if (item.type === "ol") { @@ -142,10 +124,7 @@ const LayerControl: React.FC = () => { setShowWaterflowLayer && setShowWaterflowLayer(checked); } } - - setLayerItems((prev) => - prev.map((i) => (i.id === item.id ? { ...i, visible: checked } : i)), - ); + setRefreshKey((prev) => prev + 1); }; if (!data) { diff --git a/src/components/olmap/core/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx index 25dfec7..a3ad909 100644 --- a/src/components/olmap/core/Controls/StyleEditorPanel.tsx +++ b/src/components/olmap/core/Controls/StyleEditorPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; // 导入Material-UI图标和组件 import ColorLensIcon from "@mui/icons-material/ColorLens"; @@ -180,26 +180,21 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ }) => { const map = useMap(); const data = useData(); - if (!data) { - return <div>Loading...</div>; // 或其他占位符 - } - const { - currentJunctionCalData, - currentPipeCalData, - junctionText, - pipeText, - setShowJunctionTextLayer, - setShowPipeTextLayer, - setShowJunctionId, - setShowPipeId, - setContourLayerAvailable, - setWaterflowLayerAvailable, - setJunctionText, - setPipeText, - setContours, - diameterRange, - elevationRange, - } = data; + const currentJunctionCalData = data?.currentJunctionCalData; + const currentPipeCalData = data?.currentPipeCalData; + const junctionText = data?.junctionText ?? ""; + const pipeText = data?.pipeText ?? ""; + const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; + const setShowPipeTextLayer = data?.setShowPipeTextLayer; + const setShowJunctionId = data?.setShowJunctionId; + const setShowPipeId = data?.setShowPipeId; + const setContourLayerAvailable = data?.setContourLayerAvailable; + const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable; + const setJunctionText = data?.setJunctionText; + const setPipeText = data?.setPipeText; + const setContours = data?.setContours; + const diameterRange = data?.diameterRange; + const elevationRange = data?.elevationRange; const unitHeadlossRange = [0, 5]; @@ -213,9 +208,6 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]); const [selectedRenderLayer, setSelectedRenderLayer] = useState<WebGLVectorTileLayer>(); - const [availableProperties, setAvailableProperties] = useState< - { name: string; value: string }[] - >([]); const [styleConfig, setStyleConfig] = useState<StyleConfig>({ property: "", classificationMethod: "pretty_breaks", @@ -237,6 +229,74 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ customColors: [], }); + const getDefaultCustomColors = ( + segments: number, + existingColors: string[] = [] + ) => { + const nextColors = [...existingColors]; + const baseColors = RAINBOW_PALETTES[0].colors; + + while (nextColors.length < segments) { + nextColors.push(baseColors[nextColors.length % baseColors.length]); + } + + return nextColors.slice(0, segments); + }; + + const getDefaultCustomBreaks = ( + segments: number, + property: string, + layer: WebGLVectorTileLayer | undefined = selectedRenderLayer + ) => { + if (!layer || !property) { + return Array.from({ length: segments }, () => 0); + } + + const selectedLayerId = layer.get("value"); + let dataArr: number[] = []; + + const isElevation = + selectedLayerId === "junctions" && property === "elevation"; + const isDiameter = selectedLayerId === "pipes" && property === "diameter"; + + if (isElevation && elevationRange) { + dataArr = [elevationRange[0], elevationRange[1]]; + } else if (isDiameter && diameterRange) { + dataArr = [diameterRange[0], diameterRange[1]]; + } else if (selectedLayerId === "junctions" && currentJunctionCalData) { + dataArr = currentJunctionCalData.map((d: any) => d.value); + } else if (selectedLayerId === "pipes" && currentPipeCalData) { + dataArr = currentPipeCalData.map((d: any) => d.value); + } + + if (dataArr.length === 0) { + return Array.from({ length: segments }, () => 0); + } + + const defaultBreaks = calculateClassification( + dataArr, + segments, + "pretty_breaks" + ).slice(0, segments); + + while (defaultBreaks.length < segments) { + defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0); + } + + return defaultBreaks; + }; + + const availableProperties = useMemo<{ name: string; value: string }[]>(() => { + if (!selectedRenderLayer) { + return []; + } + + return (selectedRenderLayer.get("properties") || []) as { + name: string; + value: string; + }[]; + }, [selectedRenderLayer]); + // 根据分段数生成相应数量的渐进颜色 const generateGradientColors = useCallback( (segments: number): string[] => { @@ -278,63 +338,56 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ [styleConfig.rainbowPaletteIndex] ); // 保存当前图层的样式状态 - const saveLayerStyle = useCallback( - ( - layerId?: string, - newLegendConfig?: LegendStyleConfig, - overrideStyleConfig?: StyleConfig - ) => { - const currentStyleConfig = overrideStyleConfig || styleConfig; + const saveLayerStyle = ( + layerId?: string, + newLegendConfig?: LegendStyleConfig, + overrideStyleConfig?: StyleConfig + ) => { + const currentStyleConfig = overrideStyleConfig || styleConfig; - if (!currentStyleConfig.property) { - console.warn("无法保存样式:缺少必要的图层或样式配置"); - return; + if (!currentStyleConfig.property) { + console.warn("无法保存样式:缺少必要的图层或样式配置"); + return; + } + if (!layerId) return; + + const layerName = + newLegendConfig?.layerName || + selectedRenderLayer?.get("name") || + `图层${layerId}`; + const property = availableProperties.find( + (p) => p.value === currentStyleConfig.property + ); + const legendConfig: LegendStyleConfig = newLegendConfig || { + layerId, + layerName, + property: property?.name || currentStyleConfig.property, + colors: [], + type: selectedRenderLayer?.get("type") || "point", + dimensions: [], + breaks: [], + }; + + const newStyleState: LayerStyleState = { + layerId, + layerName, + styleConfig: { ...currentStyleConfig }, + legendConfig: { ...legendConfig }, + isActive: true, + }; + + setLayerStyleStates((prev) => { + const existingIndex = prev.findIndex((state) => state.layerId === layerId); + + if (existingIndex !== -1) { + const updated = [...prev]; + updated[existingIndex] = newStyleState; + return updated; } - if (!layerId) return; // 如果没有传入 layerId,则不保存 - // 如果没有传入图例配置,则创建一个默认的空配置 - const layerName = - newLegendConfig?.layerName || - selectedRenderLayer?.get("name") || - `图层${layerId}`; - const property = availableProperties.find( - (p) => p.value === currentStyleConfig.property - ); - let legendConfig: LegendStyleConfig = newLegendConfig || { - layerId, - layerName, - property: property?.name || currentStyleConfig.property, - colors: [], - type: selectedRenderLayer?.get("type") || "point", - dimensions: [], - breaks: [], - }; - const newStyleState: LayerStyleState = { - layerId, - layerName, - styleConfig: { ...currentStyleConfig }, - legendConfig: { ...legendConfig }, - isActive: true, - }; - setLayerStyleStates((prev) => { - // 检查是否已存在该图层的样式状态 - const existingIndex = prev.findIndex( - (state) => state.layerId === layerId - ); - - if (existingIndex !== -1) { - // 更新已存在的状态 - const updated = [...prev]; - updated[existingIndex] = newStyleState; - return updated; - } else { - // 添加新的状态 - return [...prev, newStyleState]; - } - }); - }, - [selectedRenderLayer, styleConfig, availableProperties] - ); + return [...prev, newStyleState]; + }); + }; // 设置分类样式参数,触发样式应用 const setStyleState = () => { if (!selectedRenderLayer) return; @@ -787,7 +840,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ }; // 重置样式 - const resetStyle = useCallback(() => { + const resetStyle = () => { if (!selectedRenderLayer) return; // 重置 WebGL 图层样式 const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; @@ -815,7 +868,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ setWaterflowLayerAvailable && setWaterflowLayerAvailable(false); } } - }, [selectedRenderLayer]); + }; // 更新当前 VectorTileSource 中的所有缓冲要素属性 const updateVectorTileSource = (property: string, data: any[]) => { if (!map) return; @@ -857,7 +910,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ }); }; // 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性 - const [tileLoadListeners, setTileLoadListeners] = useState< + const tileLoadListenersRef = useRef< Map<VectorTileSource, (event: any) => void> >(new Map()); @@ -879,8 +932,6 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ dataMap.set(d.ID, d.value || 0); }); // 新增监听器并保存 - const newListeners = new Map<VectorTileSource, (event: any) => void>(); - const listener = (event: any) => { try { if (event.tile instanceof VectorTile) { @@ -906,8 +957,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ }; vectorTileSource.on("tileloadend", listener); - newListeners.set(vectorTileSource, listener); - setTileLoadListeners(newListeners); + tileLoadListenersRef.current.set(vectorTileSource, listener); }; // 新增函数:取消对应 layerId 已添加的 on 事件 const removeVectorTileSourceLoadedEvent = (layerId: string) => { @@ -918,14 +968,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ .map((layer) => layer.getSource() as VectorTileSource) .filter((source) => source)[0]; if (!vectorTileSource) return; - const listener = tileLoadListeners.get(vectorTileSource); + const listener = tileLoadListenersRef.current.get(vectorTileSource); if (listener) { vectorTileSource.un("tileloadend", listener); - setTileLoadListeners((prev) => { - const newMap = new Map(prev); - newMap.delete(vectorTileSource); - return newMap; - }); + tileLoadListenersRef.current.delete(vectorTileSource); } }; @@ -1044,117 +1090,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ updateVisibleLayers(); }, [map]); - // 获取选中图层的属性,并检查是否有已缓存的样式状态 - useEffect(() => { - // 如果没有矢量图层或没有选中图层,清空属性列表 - if (!renderLayers || renderLayers.length === 0) { - setAvailableProperties([]); - return; - } - // 如果没有选中图层,清空属性列表 - if (!selectedRenderLayer) { - setAvailableProperties([]); - return; - } - - // 获取第一个要素的数值型属性 - const properties = selectedRenderLayer.get("properties") || {}; - setAvailableProperties(properties); - - // 设置选中的渲染图层 - const renderLayer = renderLayers.filter((layer) => { - return layer.get("value") === selectedRenderLayer?.get("value"); - })[0]; - setSelectedRenderLayer(renderLayer); - - // 检查是否有已缓存的样式状态,如果有则自动恢复 - const layerId = selectedRenderLayer.get("value"); - const cachedStyleState = layerStyleStates.find( - (state) => state.layerId === layerId - ); - if (cachedStyleState) { - setStyleConfig(cachedStyleState.styleConfig); - } - }, [renderLayers, selectedRenderLayer, map, renderLayers, layerStyleStates]); - - // 监听颜色类型变化,当切换到单一色时自动勾选宽度调整选项 - useEffect(() => { - if (styleConfig.colorType === "single") { - setStyleConfig((prev) => ({ - ...prev, - adjustWidthByProperty: true, - })); - } - }, [styleConfig.colorType]); - - // 初始化或调整自定义断点数组长度,默认使用 pretty_breaks 生成若存在数据 - useEffect(() => { - if (styleConfig.classificationMethod !== "custom_breaks") return; - - const numBreaks = styleConfig.segments; - setStyleConfig((prev) => { - const prevBreaks = prev.customBreaks || []; - if (prevBreaks.length === numBreaks) return prev; - - const selectedLayerId = selectedRenderLayer?.get("value"); - let dataArr: number[] = []; - - const isElevation = - selectedLayerId === "junctions" && styleConfig.property === "elevation"; - const isDiameter = - selectedLayerId === "pipes" && styleConfig.property === "diameter"; - - if (isElevation && elevationRange) { - dataArr = [elevationRange[0], elevationRange[1]]; - } else if (isDiameter && diameterRange) { - dataArr = [diameterRange[0], diameterRange[1]]; - } else if (selectedLayerId === "junctions" && currentJunctionCalData) { - dataArr = currentJunctionCalData.map((d: any) => d.value); - } else if (selectedLayerId === "pipes" && currentPipeCalData) { - dataArr = currentPipeCalData.map((d: any) => d.value); - } - - let defaultBreaks: number[] = Array.from({ length: numBreaks }, () => 0); - if (dataArr && dataArr.length > 0) { - defaultBreaks = calculateClassification( - dataArr, - styleConfig.segments, - "pretty_breaks" - ); - defaultBreaks = defaultBreaks.slice(0, numBreaks); - if (defaultBreaks.length < numBreaks) - while (defaultBreaks.length < numBreaks) - defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0); - } - - return { ...prev, customBreaks: defaultBreaks }; - }); - }, [ - styleConfig.classificationMethod, - styleConfig.segments, - styleConfig.property, - selectedRenderLayer, - currentJunctionCalData, - currentPipeCalData, - elevationRange, - diameterRange, - ]); - - // 初始化或调整自定义颜色数组长度 - useEffect(() => { - const numColors = styleConfig.segments; - setStyleConfig((prev) => { - const prevColors = prev.customColors || []; - if (prevColors.length === numColors) return prev; - - const newColors = [...prevColors]; - const baseColors = RAINBOW_PALETTES[0].colors; - while (newColors.length < numColors) { - newColors.push(baseColors[newColors.length % baseColors.length]); - } - return { ...prev, customColors: newColors.slice(0, numColors) }; - }); - }, [styleConfig.segments]); + if (!data) { + return <div>Loading...</div>; + } const getColorSetting = () => { if (styleConfig.colorType === "single") { @@ -1624,9 +1562,21 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ const cachedStyleState = layerStyleStates.find( (state) => state.layerId === layerId ); - // 只有在没有缓存时才清空属性 - if (!cachedStyleState) { - setStyleConfig((prev) => ({ ...prev, property: "" })); + if (cachedStyleState) { + setStyleConfig(cachedStyleState.styleConfig); + } else { + setStyleConfig((prev) => ({ + ...prev, + property: "", + customBreaks: + prev.classificationMethod === "custom_breaks" + ? getDefaultCustomBreaks(prev.segments, "", newLayer) + : prev.customBreaks, + customColors: getDefaultCustomColors( + prev.segments, + prev.customColors + ), + })); } } }} @@ -1647,7 +1597,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ <Select value={styleConfig.property} onChange={(e) => { - setStyleConfig((prev) => ({ ...prev, property: e.target.value })); + const nextProperty = e.target.value; + setStyleConfig((prev) => ({ + ...prev, + property: nextProperty, + customBreaks: + prev.classificationMethod === "custom_breaks" + ? getDefaultCustomBreaks(prev.segments, nextProperty) + : prev.customBreaks, + })); }} disabled={!selectedRenderLayer} > @@ -1664,9 +1622,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ <Select value={styleConfig.classificationMethod} onChange={(e) => { + const nextMethod = e.target.value; setStyleConfig((prev) => ({ ...prev, - classificationMethod: e.target.value, + classificationMethod: nextMethod, + customBreaks: + nextMethod === "custom_breaks" + ? getDefaultCustomBreaks(prev.segments, prev.property) + : prev.customBreaks, })); }} > @@ -1695,7 +1658,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ return { ...prev, segments: newSegments, - customColors: newCustomColors, + customBreaks: + prev.classificationMethod === "custom_breaks" + ? getDefaultCustomBreaks(newSegments, prev.property) + : prev.customBreaks, + customColors: getDefaultCustomColors( + newSegments, + newCustomColors + ), }; }) } @@ -1782,6 +1752,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ return { ...prev, colorType: newColorType, + adjustWidthByProperty: + newColorType === "single" + ? true + : prev.adjustWidthByProperty, customColors: newCustomColors, }; }); diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index 208e984..eefdeed 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -47,29 +47,21 @@ const Timeline: React.FC<TimelineProps> = ({ schemeType = "burst_Analysis", }) => { const data = useData(); - if (!data) { - return <div>Loading...</div>; // 或其他占位符 - } - const { - currentTime, - setCurrentTime, - selectedDate, - setSelectedDate, - setCurrentJunctionCalData, - setCurrentPipeCalData, - junctionText, - pipeText, - } = data; - if ( - setCurrentTime === undefined || - currentTime === undefined || - selectedDate === undefined || - setSelectedDate === undefined - ) { - return <div>Loading...</div>; // 或其他占位符 - } + const hasTimelineState = + data && + data.setCurrentTime !== undefined && + data.currentTime !== undefined && + data.selectedDate !== undefined && + data.setSelectedDate !== undefined; + const currentTime = data?.currentTime ?? -1; + const setCurrentTime = data?.setCurrentTime ?? ((_: any) => undefined); + const selectedDate = data?.selectedDate ?? new Date(); + const setSelectedDate = data?.setSelectedDate ?? ((_: any) => undefined); + const setCurrentJunctionCalData = data?.setCurrentJunctionCalData; + const setCurrentPipeCalData = data?.setCurrentPipeCalData; + const junctionText = data?.junctionText ?? ""; + const pipeText = data?.pipeText ?? ""; const { open } = useNotification(); - const [isPlaying, setIsPlaying] = useState<boolean>(false); const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒 const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟 @@ -549,6 +541,10 @@ const Timeline: React.FC<TimelineProps> = ({ } }; + if (!hasTimelineState) { + return <div>Loading...</div>; + } + return ( <Draggable nodeRef={draggableRef} handle=".drag-handle"> <div diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 14afb0f..c5f476c 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -39,8 +39,6 @@ const Toolbar: React.FC<ToolbarProps> = ({ const map = useMap(); const data = useData(); const { open } = useNotification(); - if (!data) return null; - const { currentTime, selectedDate, schemeName } = data; const [activeTools, setActiveTools] = useState<string[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [showPropertyPanel, setShowPropertyPanel] = useState<boolean>(false); @@ -49,6 +47,9 @@ const Toolbar: React.FC<ToolbarProps> = ({ const [showHistoryPanel, setShowHistoryPanel] = useState<boolean>(false); const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); + const currentTime = data?.currentTime; + const selectedDate = data?.selectedDate; + const schemeName = data?.schemeName; // 样式状态管理 - 在 Toolbar 中管理,带有默认样式 const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([ @@ -721,6 +722,10 @@ const Toolbar: React.FC<ToolbarProps> = ({ return {}; }, [highlightFeatures, computedProperties]); + if (!data) { + return null; + } + return ( <> <div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity"> diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index d319678..e142b47 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -78,15 +78,33 @@ const MapContext = createContext<OlMap | undefined>(undefined); const DataContext = createContext<DataContextType | undefined>(undefined); // 添加防抖函数 -function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) { +type DebouncedFunction<F extends (...args: any[]) => any> = (( + ...args: Parameters<F> +) => void) & { + cancel: () => void; +}; + +function debounce<F extends (...args: any[]) => any>( + func: F, + waitFor: number +): DebouncedFunction<F> { let timeout: ReturnType<typeof setTimeout> | null = null; - return (...args: Parameters<F>): void => { + const debounced = (...args: Parameters<F>): void => { if (timeout !== null) { clearTimeout(timeout); } timeout = setTimeout(() => func(...args), waitFor); }; + + debounced.cancel = () => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; + + return debounced; } export const useMap = () => { @@ -187,20 +205,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { [number, number] | undefined >(); - // 防抖更新函数 - const debouncedUpdateData = useRef( - debounce(() => { - if (tileJunctionDataBuffer.current.length > 0) { - setJunctionData(tileJunctionDataBuffer.current); - tileJunctionDataBuffer.current = []; - } - if (tilePipeDataBuffer.current.length > 0) { - setPipeData(tilePipeDataBuffer.current); - tilePipeDataBuffer.current = []; - } - }, 100), - ); - const setJunctionData = (newData: any[]) => { const uniqueNewData = newData.filter((item) => { if (!item || !item.id) return false; @@ -232,6 +236,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }); } }; + const setPipeData = (newData: any[]) => { const uniqueNewData = newData.filter((item) => { if (!item || !item.id) return false; @@ -263,6 +268,28 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }); } }; + + const debouncedUpdateDataRef = useRef<DebouncedFunction<() => void> | null>( + null, + ); + + useEffect(() => { + debouncedUpdateDataRef.current = debounce(() => { + if (tileJunctionDataBuffer.current.length > 0) { + setJunctionData(tileJunctionDataBuffer.current); + tileJunctionDataBuffer.current = []; + } + if (tilePipeDataBuffer.current.length > 0) { + setPipeData(tilePipeDataBuffer.current); + tilePipeDataBuffer.current = []; + } + }, 100); + + return () => { + debouncedUpdateDataRef.current?.cancel(); + debouncedUpdateDataRef.current = null; + }; + }, []); // 配置地图数据源、图层和样式 const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; // 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标 @@ -520,7 +547,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { uniqueData.forEach((item) => tileJunctionDataBuffer.current.push(item), ); - debouncedUpdateData.current(); + debouncedUpdateDataRef.current?.(); } } } catch (error) { @@ -600,7 +627,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const uniqueData = Array.from(data.values()); if (uniqueData.length > 0) { uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item)); - debouncedUpdateData.current(); + debouncedUpdateDataRef.current?.(); } } } catch (error) { diff --git a/src/contexts/color-mode/index.tsx b/src/contexts/color-mode/index.tsx index bfd7936..9027599 100644 --- a/src/contexts/color-mode/index.tsx +++ b/src/contexts/color-mode/index.tsx @@ -29,26 +29,20 @@ type ColorModeContextProviderProps = { export const ColorModeContextProvider: React.FC< PropsWithChildren<ColorModeContextProviderProps> > = ({ children, defaultMode }) => { - const [isMounted, setIsMounted] = useState(false); - const [mode, setMode] = useState(defaultMode || "light"); - - useEffect(() => { - setIsMounted(true); - }, []); - const systemTheme = useMediaQuery(`(prefers-color-scheme: dark)`); - - useEffect(() => { - if (isMounted) { - const theme = Cookies.get("theme") || (systemTheme ? "dark" : "light"); - setMode(theme); + const [storedMode, setStoredMode] = useState<string | null>(() => { + if (typeof window === "undefined") { + return defaultMode ?? null; } - }, [isMounted, systemTheme]); + + return Cookies.get("theme") || defaultMode || null; + }); + const mode = storedMode || (systemTheme ? "dark" : "light"); const toggleTheme = () => { const nextTheme = mode === "light" ? "dark" : "light"; - setMode(nextTheme); + setStoredMode(nextTheme); Cookies.set("theme", nextTheme); }; -- 2.54.0 From f0f9d3f4f93cddaf305f347bba120b0a2fe56035 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Tue, 10 Mar 2026 18:15:11 +0800 Subject: [PATCH 045/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20lint=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.mjs | 8 ++ src/app/login/page.tsx | 5 +- .../BurstSimulation/AnalysisParameters.tsx | 73 +++++++------- .../olmap/BurstSimulation/SchemeQuery.tsx | 2 +- .../AnalysisParameters.tsx | 54 +++++----- .../DMALeakDetectionPanel.tsx | 2 +- .../FlushingAnalysis/AnalysisParameters.tsx | 98 +++++++++---------- .../olmap/HealthRiskAnalysis/Timeline.tsx | 12 +-- .../SchemeQuery.tsx | 2 +- src/components/olmap/SCADA/SCADADataPanel.tsx | 3 +- .../olmap/SCADA/SCADADeviceList.tsx | 2 +- .../olmap/core/Controls/BaseLayers.tsx | 9 +- .../olmap/core/Controls/HistoryDataPanel.tsx | 6 +- .../olmap/core/Controls/LayerControl.tsx | 30 +++--- .../olmap/core/Controls/StyleEditorPanel.tsx | 4 +- .../olmap/core/Controls/Timeline.tsx | 66 +++++++------ .../olmap/core/Controls/Toolbar.tsx | 5 +- src/components/olmap/core/MapComponent.tsx | 2 + src/contexts/ProjectContext.tsx | 42 ++++---- 19 files changed, 225 insertions(+), 200 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 427cf11..f07358f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "refine.ams3.cdn.digitaloceanspaces.com", + }, + ], + }, turbopack: { rules: { "*.svg": { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ee6f578..089a9ed 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Container from "@mui/material/Container"; @@ -38,10 +39,12 @@ export default function Login() { </Button> <Typography align="center" color={"text.secondary"} fontSize="12px"> Powered by - <img + <Image style={{ padding: "0 5px" }} alt="Keycloak" src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg" + width={18} + height={18} /> Keycloak </Typography> diff --git a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx index 5482313..b39756a 100644 --- a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx @@ -61,6 +61,39 @@ const AnalysisParameters: React.FC = () => { duration > 0 && schemeName.trim() !== ""; + // 地图点击选择要素事件处理函数 + const handleMapClickSelectFeatures = useCallback( + async (event: { coordinate: number[] }) => { + if (!map) return; + const feature = await mapClickSelectFeatures(event, map); + const layer = feature?.getId()?.toString().split(".")[0]; + + if (!feature) return; + if ( + feature.getGeometry()?.getType() === "Point" || + (layer !== "geo_pipes_mat" && layer !== "geo_pipes") + ) { + open?.({ + type: "error", + message: "请选择线类型管道要素。", + }); + return; + } + const featureId = feature.getProperties().id; + setHighlightFeatures((prev) => { + const existingIndex = prev.findIndex( + (f) => f.getProperties().id === featureId, + ); + if (existingIndex !== -1) { + return prev.filter((_, i) => i !== existingIndex); + } else { + return [...prev, feature]; + } + }); + }, + [map, open], + ); + // 初始化管道图层和高亮图层 useEffect(() => { if (!map) return; @@ -137,7 +170,7 @@ const AnalysisParameters: React.FC = () => { map.removeLayer(highlightLayer); map.un("click", handleMapClickSelectFeatures); }; - }, [map]); + }, [map, handleMapClickSelectFeatures]); // 高亮要素的函数 useEffect(() => { if (!highlightLayer) { @@ -155,7 +188,7 @@ const AnalysisParameters: React.FC = () => { source.addFeature(feature); } }); - }, [highlightFeatures]); + }, [highlightFeatures, highlightLayer]); // 同步高亮要素和爆管点信息 useEffect(() => { @@ -185,42 +218,6 @@ const AnalysisParameters: React.FC = () => { }); }, [highlightFeatures]); - // 地图点击选择要素事件处理函数 - const handleMapClickSelectFeatures = useCallback( - async (event: { coordinate: number[] }) => { - if (!map) return; - const feature = await mapClickSelectFeatures(event, map); - const layer = feature?.getId()?.toString().split(".")[0]; - - if (!feature) return; - if ( - feature.getGeometry()?.getType() === "Point" || - (layer !== "geo_pipes_mat" && layer !== "geo_pipes") - ) { - // 点类型几何不处理 - open?.({ - type: "error", - message: "请选择线类型管道要素。", - }); - return; - } - const featureId = feature.getProperties().id; - setHighlightFeatures((prev) => { - const existingIndex = prev.findIndex( - (f) => f.getProperties().id === featureId, - ); - if (existingIndex !== -1) { - // 如果已存在,移除 - return prev.filter((_, i) => i !== existingIndex); - } else { - // 如果不存在,添加 - return [...prev, feature]; - } - }); - }, - [map], - ); - // 开始选择管道 const handleStartSelection = () => { if (!map) return; diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index 6256d16..65fce5c 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -299,7 +299,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ source.addFeature(feature); } }); - }, [highlightFeatures]); + }, [highlightFeatures, highlightLayer]); return ( <> diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index 8303e65..66f95ea 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -59,6 +59,32 @@ const AnalysisParameters: React.FC = () => { ); }, [network, startTime, sourceNode, concentration, duration, schemeName]); + const handleMapClickSelectFeatures = useCallback( + async (event: { coordinate: number[] }) => { + if (!map) return; + const feature = await mapClickSelectFeatures(event, map); + if (!feature) return; + + const layerId = feature.getId()?.toString().split(".")[0] || ""; + const isJunction = layerId.includes("junction"); + if (!isJunction) { + open?.({ + type: "error", + message: "请选择节点类型要素作为污染源。", + }); + return; + } + + const id = feature.getProperties().id; + if (!id) return; + setSourceNode(id); + setHighlightFeature(feature); + setIsSelecting(false); + map.un("click", handleMapClickSelectFeatures); + }, + [map, open], + ); + useEffect(() => { if (!map) return; @@ -106,7 +132,7 @@ const AnalysisParameters: React.FC = () => { map.removeLayer(layer); map.un("click", handleMapClickSelectFeatures); }; - }, [map]); + }, [map, handleMapClickSelectFeatures]); useEffect(() => { if (!highlightLayer) return; @@ -118,32 +144,6 @@ const AnalysisParameters: React.FC = () => { } }, [highlightFeature, highlightLayer]); - const handleMapClickSelectFeatures = useCallback( - async (event: { coordinate: number[] }) => { - if (!map) return; - const feature = await mapClickSelectFeatures(event, map); - if (!feature) return; - - const layerId = feature.getId()?.toString().split(".")[0] || ""; - const isJunction = layerId.includes("junction"); - if (!isJunction) { - open?.({ - type: "error", - message: "请选择节点类型要素作为污染源。", - }); - return; - } - - const id = feature.getProperties().id; - if (!id) return; - setSourceNode(id); - setHighlightFeature(feature); - setIsSelecting(false); - map.un("click", handleMapClickSelectFeatures); - }, - [map, open], - ); - const handleStartSelection = () => { if (!map) return; setIsSelecting(true); diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index 9dd5fd3..ec36d49 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -55,7 +55,7 @@ const DMALeakDetectionPanel: React.FC = () => { const drawerWidth = 450; const panelTitle = "DMA 漏损识别"; - const activeAreas = loadedResult?.areas ?? []; + const activeAreas = useMemo(() => loadedResult?.areas ?? [], [loadedResult]); const legendColors = useMemo( () => activeAreas.map((area) => getAreaColor(area.area_id)), [activeAreas], diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 40dbb9f..b9e07e8 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -55,6 +55,54 @@ const AnalysisParameters: React.FC = () => { const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); + // Map click handler + const handleMapClickSelectFeatures = useCallback( + async (event: { coordinate: number[] }) => { + if (!map || selectionMode === 'none') return; + + const feature = await mapClickSelectFeatures(event, map); + if (!feature) return; + + const layer = feature.getId()?.toString().split(".")[0]; + const featureId = feature.getProperties().id; + + if (selectionMode === 'valve') { + if (layer !== 'geo_valves') { + open?.({ + type: "error", + message: "请选择阀门要素", + }); + return; + } + + setValves((prev) => { + if (prev.some((v) => v.id === featureId)) { + open?.({ + type: "error", + message: "该阀门已添加", + }); + return prev; + } + return [...prev, { id: featureId, k: 1.0, feature }]; + }); + + } else if (selectionMode === 'drainage') { + if (layer !== 'geo_junctions') { + open?.({ + type: "error", + message: "请选择节点要素作为排水点", + }); + return; + } + setDrainageNode(featureId); + setDrainageFeature(feature); + setSelectionMode('none'); + map.un("click", handleMapClickSelectFeatures); + } + }, + [map, selectionMode, open] + ); + // Initialize highlight layer useEffect(() => { if (!map) return; @@ -103,7 +151,7 @@ const AnalysisParameters: React.FC = () => { map.removeLayer(layer); map.un("click", handleMapClickSelectFeatures); }; - }, [map]); + }, [map, handleMapClickSelectFeatures]); // Update highlight layer features useEffect(() => { @@ -134,54 +182,6 @@ const AnalysisParameters: React.FC = () => { }, [highlightLayer, valves, drainageFeature]); - // Map click handler - const handleMapClickSelectFeatures = useCallback( - async (event: { coordinate: number[] }) => { - if (!map || selectionMode === 'none') return; - - const feature = await mapClickSelectFeatures(event, map); - if (!feature) return; - - const layer = feature.getId()?.toString().split(".")[0]; - const featureId = feature.getProperties().id; - - if (selectionMode === 'valve') { - if (layer !== 'geo_valves') { - open?.({ - type: "error", - message: "请选择阀门要素", - }); - return; - } - - setValves((prev) => { - if (prev.some((v) => v.id === featureId)) { - open?.({ - type: "error", - message: "该阀门已添加", - }); - return prev; - } - return [...prev, { id: featureId, k: 1.0, feature }]; // Default k=1.0? User can change. - }); - - } else if (selectionMode === 'drainage') { - if (layer !== 'geo_junctions') { - open?.({ - type: "error", - message: "请选择节点要素作为排水点", - }); - return; - } - setDrainageNode(featureId); - setDrainageFeature(feature); - setSelectionMode('none'); // Auto exit selection after picking one - map.un("click", handleMapClickSelectFeatures); - } - }, - [map, selectionMode, open] - ); - // Bind click event based on selection mode useEffect(() => { if (!map || selectionMode === "none") return; diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index a4f0bde..9d8742b 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -117,7 +117,7 @@ const Timeline: React.FC<TimelineProps> = ({ setCurrentYear(value); }, 500); // 500ms 防抖延迟 }, - [minTime, maxTime], + [minTime, maxTime, setCurrentYear], ); // 播放控制 @@ -133,7 +133,7 @@ const Timeline: React.FC<TimelineProps> = ({ }); }, playInterval); } - }, [isPlaying, playInterval]); + }, [isPlaying, playInterval, maxTime, minTime, setCurrentYear]); const handlePause = useCallback(() => { setIsPlaying(false); @@ -172,7 +172,7 @@ const Timeline: React.FC<TimelineProps> = ({ if (next < minTime) next += maxTime - minTime + 1; return next; }); - }, [minTime, maxTime]); + }, [minTime, maxTime, setCurrentYear]); const handleStepForward = useCallback(() => { setCurrentYear((prev: number) => { @@ -180,7 +180,7 @@ const Timeline: React.FC<TimelineProps> = ({ if (next > maxTime) next = minTime; return next; }); - }, [minTime, maxTime]); + }, [minTime, maxTime, setCurrentYear]); // 日期时间选择处理 const handleDateTimeChange = useCallback((newDate: Date | null) => { @@ -207,7 +207,7 @@ const Timeline: React.FC<TimelineProps> = ({ }, newInterval); } }, - [isPlaying], + [isPlaying, maxTime, minTime, setCurrentYear], ); // 组件加载时设置初始时间为当前时间的最近15分钟 @@ -372,7 +372,7 @@ const Timeline: React.FC<TimelineProps> = ({ if (predictionResults.length > 0 && pipeLayer) { applyPipeHealthStyle(); } - }, [applyPipeHealthStyle]); + }, [applyPipeHealthStyle, pipeLayer, predictionResults.length]); // 这里防止地图缩放时,瓦片重新加载引起的属性更新出错 useEffect(() => { diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 23198b4..be17175 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -140,7 +140,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ source.addFeature(feature); } }); - }, [highlightFeatures]); + }, [highlightFeatures, highlightLayer]); // 查询方案 const handleQuery = async () => { diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index a788247..cf481f0 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -416,6 +416,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ const hasDevices = deviceIds.length > 0; const hasData = timeSeries.length > 0; + const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]); const dataset = useMemo( () => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning), @@ -528,7 +529,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ } else { setTimeSeries([]); } - }, [deviceIds.join(",")]); + }, [deviceIdsKey, handleFetch, hasDevices]); // 当设备数量变化时,调整数据源选择 useEffect(() => { diff --git a/src/components/olmap/SCADA/SCADADeviceList.tsx b/src/components/olmap/SCADA/SCADADeviceList.tsx index 9626b1c..f93e2d4 100644 --- a/src/components/olmap/SCADA/SCADADeviceList.tsx +++ b/src/components/olmap/SCADA/SCADADeviceList.tsx @@ -741,7 +741,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ source.addFeature(feature); } }); - }, [selectedDeviceIds, highlightFeatures]); + }, [selectedDeviceIds, highlightFeatures, highlightLayer]); // 清理定时器 useEffect(() => { return () => { diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 03f2a76..244323f 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import Image from "next/image"; import { useMap } from "../MapComponent"; import TileLayer from "ol/layer/Tile.js"; import XYZ from "ol/source/XYZ.js"; @@ -136,7 +137,7 @@ const BaseLayers: React.FC = () => { } layerInfo.layer.setVisible(layerInfo.id === activeId); }); - }, [map]); + }, [map, activeId]); const changeMapLayers = (id: string) => { if (map) { @@ -187,7 +188,7 @@ const BaseLayers: React.FC = () => { > <div className="w-20 h-20 p-1"> <button onClick={() => handleQuickSwitch()}> - <img + <Image width={240} height={100} src={ @@ -200,6 +201,7 @@ const BaseLayers: React.FC = () => { ? baseLayers[1].name : baseLayers[0].name } + sizes="72px" className="object-cover object-left w-18 h-18 rounded-xl" /> <div className=" absolute left-1 bottom-1 flex w-18 h-auto items-center justify-center rounded-b-xl text-xs text-white bg-black opacity-80"> @@ -227,11 +229,12 @@ const BaseLayers: React.FC = () => { className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs" onClick={() => handleMapLayers(item.id)} > - <img + <Image width={240} height={100} src={item.img} alt={item.name} + sizes="64px" className={clsx( "object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300", { diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index bd4f8c5..e8fb328 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -422,6 +422,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ const hasDevices = deviceIds.length > 0; const hasData = timeSeries.length > 0; + const featureInfosKey = useMemo( + () => JSON.stringify(featureInfos), + [featureInfos] + ); const dataset = useMemo( () => buildDataset(timeSeries, deviceIds, fractionDigits), @@ -468,7 +472,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ } else { setTimeSeries([]); } - }, [JSON.stringify(featureInfos)]); + }, [featureInfosKey, handleFetch, hasDevices]); // 当设备数量变化时,调整数据源选择 useEffect(() => { diff --git a/src/components/olmap/core/Controls/LayerControl.tsx b/src/components/olmap/core/Controls/LayerControl.tsx index d71fe10..c3dec4c 100644 --- a/src/components/olmap/core/Controls/LayerControl.tsx +++ b/src/components/olmap/core/Controls/LayerControl.tsx @@ -15,6 +15,18 @@ interface LayerItem { layerRef: any; // OpenLayers Layer 实例或 deck.gl layer 对象 } +const LAYER_ORDER = [ + "junctions", + "reservoirs", + "tanks", + "pipes", + "pumps", + "valves", + "scada", + "waterflowLayer", + "junctionContourLayer", +]; + const LayerControl: React.FC = () => { const map = useMap(); const data = useData(); @@ -25,19 +37,9 @@ const LayerControl: React.FC = () => { const setShowWaterflowLayer = data?.setShowWaterflowLayer; const setShowContourLayer = data?.setShowContourLayer; - const layerOrder = [ - "junctions", - "reservoirs", - "tanks", - "pipes", - "pumps", - "valves", - "scada", - "waterflowLayer", - "junctionContourLayer", - ]; - const layerItems = useMemo(() => { + void refreshKey; + if (!map || !data) return []; const items: LayerItem[] = []; @@ -87,8 +89,8 @@ const LayerControl: React.FC = () => { } return items - .filter((item) => layerOrder.includes(item.id)) - .sort((a, b) => layerOrder.indexOf(a.id) - layerOrder.indexOf(b.id)); + .filter((item) => LAYER_ORDER.includes(item.id)) + .sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id)); }, [ map, data, diff --git a/src/components/olmap/core/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx index a3ad909..ebafaba 100644 --- a/src/components/olmap/core/Controls/StyleEditorPanel.tsx +++ b/src/components/olmap/core/Controls/StyleEditorPanel.tsx @@ -321,7 +321,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ } return colors; }, - [styleConfig.gradientPaletteIndex, parseColor] + [styleConfig.gradientPaletteIndex] ); // 根据分段数生成彩虹色 @@ -1065,6 +1065,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ if (!applyPipeStyle) { removeVectorTileSourceLoadedEvent("pipes"); } + // This effect is intentionally driven by explicit style triggers and data snapshots. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ styleUpdateTrigger, applyJunctionStyle, diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index eefdeed..f883634 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -39,6 +39,9 @@ interface TimelineProps { schemeType?: string; } +const NOOP_SET_CURRENT_TIME = (_: any) => undefined; +const NOOP_SET_SELECTED_DATE = (_: any) => undefined; + const Timeline: React.FC<TimelineProps> = ({ schemeDate, timeRange, @@ -47,6 +50,7 @@ const Timeline: React.FC<TimelineProps> = ({ schemeType = "burst_Analysis", }) => { const data = useData(); + const fallbackSelectedDateRef = useRef(new Date()); const hasTimelineState = data && data.setCurrentTime !== undefined && @@ -54,9 +58,9 @@ const Timeline: React.FC<TimelineProps> = ({ data.selectedDate !== undefined && data.setSelectedDate !== undefined; const currentTime = data?.currentTime ?? -1; - const setCurrentTime = data?.setCurrentTime ?? ((_: any) => undefined); - const selectedDate = data?.selectedDate ?? new Date(); - const setSelectedDate = data?.setSelectedDate ?? ((_: any) => undefined); + const setCurrentTime = data?.setCurrentTime ?? NOOP_SET_CURRENT_TIME; + const selectedDate = data?.selectedDate ?? fallbackSelectedDateRef.current; + const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE; const setCurrentJunctionCalData = data?.setCurrentJunctionCalData; const setCurrentPipeCalData = data?.setCurrentPipeCalData; const junctionText = data?.junctionText ?? ""; @@ -78,7 +82,7 @@ const Timeline: React.FC<TimelineProps> = ({ if (schemeDate) { setSelectedDate(schemeDate); } - }, [schemeDate]); + }, [schemeDate, setSelectedDate]); // 新增:用于 Draggable 的 nodeRef const draggableRef = useRef<HTMLDivElement>(null); @@ -90,7 +94,20 @@ const Timeline: React.FC<TimelineProps> = ({ // 添加防抖引用 const debounceRef = useRef<NodeJS.Timeout | null>(null); - const fetchFrameData = async ( + const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => { + if (setCurrentJunctionCalData) { + setCurrentJunctionCalData(nodeResults); + } else { + console.log("setCurrentJunctionCalData is undefined"); + } + if (setCurrentPipeCalData) { + setCurrentPipeCalData(linkResults); + } else { + console.log("setCurrentPipeCalData is undefined"); + } + }, [setCurrentJunctionCalData, setCurrentPipeCalData]); + + const fetchFrameData = useCallback(async ( queryTime: Date, junctionProperties: string, pipeProperties: string, @@ -170,21 +187,7 @@ const Timeline: React.FC<TimelineProps> = ({ } // 更新状态 updateDataStates(nodeRecords.results || [], linkRecords.results || []); - }; - - // 提取更新状态的逻辑 - const updateDataStates = (nodeResults: any[], linkResults: any[]) => { - if (setCurrentJunctionCalData) { - setCurrentJunctionCalData(nodeResults); - } else { - console.log("setCurrentJunctionCalData is undefined"); - } - if (setCurrentPipeCalData) { - setCurrentPipeCalData(linkResults); - } else { - console.log("setCurrentPipeCalData is undefined"); - } - }; + }, [disableDateSelection, updateDataStates]); // 时间刻度数组 (每5分钟一个刻度) const timeMarks = Array.from({ length: 288 }, (_, i) => ({ @@ -241,7 +244,7 @@ const Timeline: React.FC<TimelineProps> = ({ setCurrentTime(value); }, 500); // 500ms 防抖延迟 }, - [timeRange, minTime, maxTime], + [timeRange, minTime, maxTime, setCurrentTime], ); // 播放控制 @@ -268,7 +271,7 @@ const Timeline: React.FC<TimelineProps> = ({ }); }, playInterval); } - }, [isPlaying, playInterval]); + }, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]); const handlePause = useCallback(() => { setIsPlaying(false); @@ -288,7 +291,7 @@ const Timeline: React.FC<TimelineProps> = ({ clearInterval(intervalRef.current); intervalRef.current = null; } - }, []); + }, [setCurrentTime]); // 步进控制 const handleDayStepBackward = useCallback(() => { @@ -297,14 +300,14 @@ const Timeline: React.FC<TimelineProps> = ({ newDate.setDate(newDate.getDate() - 1); return newDate; }); - }, []); + }, [setSelectedDate]); const handleDayStepForward = useCallback(() => { setSelectedDate((prev) => { const newDate = new Date(prev); newDate.setDate(newDate.getDate() + 1); return newDate; }); - }, []); + }, [setSelectedDate]); const handleStepBackward = useCallback(() => { setCurrentTime((prev) => { let next = prev - 15; @@ -315,7 +318,7 @@ const Timeline: React.FC<TimelineProps> = ({ } return next; }); - }, [timeRange, minTime, maxTime]); + }, [timeRange, minTime, maxTime, setCurrentTime]); const handleStepForward = useCallback(() => { setCurrentTime((prev) => { @@ -327,14 +330,14 @@ const Timeline: React.FC<TimelineProps> = ({ } return next; }); - }, [timeRange, minTime, maxTime]); + }, [timeRange, minTime, maxTime, setCurrentTime]); // 日期选择处理 const handleDateChange = useCallback((newDate: Date | null) => { if (newDate) { setSelectedDate(newDate); } - }, []); + }, [setSelectedDate]); // 播放间隔改变处理 const handleIntervalChange = useCallback( @@ -358,7 +361,7 @@ const Timeline: React.FC<TimelineProps> = ({ }, newInterval); } }, - [isPlaying], + [isPlaying, timeRange, maxTime, minTime, setCurrentTime], ); // 计算时间段改变处理 const handleCalculatedIntervalChange = useCallback((event: any) => { @@ -389,6 +392,7 @@ const Timeline: React.FC<TimelineProps> = ({ ); } }, [ + fetchFrameData, junctionText, pipeText, currentTime, @@ -414,14 +418,14 @@ const Timeline: React.FC<TimelineProps> = ({ clearTimeout(debounceRef.current); } }; - }, []); + }, [setCurrentTime]); // 当 timeRange 改变时,设置 currentTime 到 minTime useEffect(() => { if (timeRange) { setCurrentTime(minTime); } - }, [timeRange, minTime]); + }, [timeRange, minTime, setCurrentTime]); // 获取地图实例 const map = useMap(); // 这里防止地图缩放时,瓦片重新加载引起的属性更新出错 diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index c5f476c..0c7345c 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -409,8 +409,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ setComputedProperties({}); } else { setComputedProperties(data.result[0] || {}); - console.log("查询到的计算属性:", data.result[0]); - console.log(computedProperties); + // console.log("查询到的计算属性:", data.result[0]); } } catch (error) { console.error("Error querying computed properties:", error); @@ -419,7 +418,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ }; // 仅当 currentTime 有效时查询 if (currentTime !== -1 && queryType) queryComputedProperties(); - }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType]); + }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]); // 从要素属性中提取属性面板需要的数据 const getFeatureProperties = useCallback(() => { diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index e142b47..0374982 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -515,6 +515,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }, }); + // The map and layer instances are intentionally rebuilt only when workspace or extent changes. useEffect(() => { if (!mapRef.current) return; // 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示 @@ -807,6 +808,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { map.dispose(); deck.finalize(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [MAP_WORKSPACE, MAP_EXTENT]); // 当数据变化时,更新 deck.gl 图层 diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index a128613..98d69cc 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { createContext, useContext, useEffect, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; import { useSession } from "next-auth/react"; import { config, NETWORK_NAME, setMapWorkspace, setNetworkName, setMapExtent } from "@/config/config"; import { ProjectSelector } from "@/components/project/ProjectSelector"; @@ -28,25 +28,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ extent: config.MAP_EXTENT, }); - useEffect(() => { - // Check localStorage - const savedWorkspace = localStorage.getItem("NEXT_PUBLIC_MAP_WORKSPACE"); - const savedNetwork = localStorage.getItem("NEXT_PUBLIC_NETWORK_NAME"); - const savedExtent = localStorage.getItem("NEXT_PUBLIC_MAP_EXTENT"); - const savedProjectId = localStorage.getItem("active_project"); - - // If we have saved config, use it. - if (savedWorkspace && savedNetwork) { - applyConfig( - savedProjectId || savedNetwork || savedWorkspace, - savedWorkspace, - savedNetwork, - savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT, - ); - } - }, []); - - const applyConfig = async ( + const applyConfig = useCallback(async ( projectId: string, ws: string, net: string, @@ -91,7 +73,25 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ } catch (error) { console.error("Failed to open project:", error); } - }; + }, [setCurrentProjectId]); + + useEffect(() => { + // Check localStorage + const savedWorkspace = localStorage.getItem("NEXT_PUBLIC_MAP_WORKSPACE"); + const savedNetwork = localStorage.getItem("NEXT_PUBLIC_NETWORK_NAME"); + const savedExtent = localStorage.getItem("NEXT_PUBLIC_MAP_EXTENT"); + const savedProjectId = localStorage.getItem("active_project"); + + // If we have saved config, use it. + if (savedWorkspace && savedNetwork) { + applyConfig( + savedProjectId || savedNetwork || savedWorkspace, + savedWorkspace, + savedNetwork, + savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT, + ); + } + }, [applyConfig]); // Only show selector if authenticated and not configured if (status === "authenticated" && !isConfigured) { -- 2.54.0 From e2ea1853f19b44ea402a8bf9706d69ee9448617a Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Mar 2026 16:40:09 +0800 Subject: [PATCH 046/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=88=86=E7=AE=A1?= =?UTF-8?q?=E4=BE=A6=E6=B5=8B=E9=9D=A2=E6=9D=BF=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../burst-detection/loading.tsx | 5 + .../burst-detection/page.tsx | 16 + src/app/_refine_context.tsx | 12 +- .../BurstDetection/AnalysisParameters.tsx | 471 ++++++++++++++ .../BurstDetection/BurstDetectionPanel.tsx | 153 +++++ .../olmap/BurstDetection/DetectionResults.tsx | 610 ++++++++++++++++++ .../olmap/BurstDetection/SchemeQuery.tsx | 350 ++++++++++ src/components/olmap/BurstDetection/types.ts | 77 +++ 8 files changed, 1692 insertions(+), 2 deletions(-) create mode 100644 src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx create mode 100644 src/app/(main)/hydraulic-simulation/burst-detection/page.tsx create mode 100644 src/components/olmap/BurstDetection/AnalysisParameters.tsx create mode 100644 src/components/olmap/BurstDetection/BurstDetectionPanel.tsx create mode 100644 src/components/olmap/BurstDetection/DetectionResults.tsx create mode 100644 src/components/olmap/BurstDetection/SchemeQuery.tsx create mode 100644 src/components/olmap/BurstDetection/types.ts diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx b/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx new file mode 100644 index 0000000..2c57921 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx @@ -0,0 +1,5 @@ +import { MapSkeleton } from "@components/loading/MapSkeleton"; + +export default function Loading() { + return <MapSkeleton />; +} diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx b/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx new file mode 100644 index 0000000..df2ddb9 --- /dev/null +++ b/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import MapComponent from "@components/olmap/core/MapComponent"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel"; + +export default function Home() { + return ( + <div className="relative h-full w-full overflow-hidden"> + <MapComponent> + <MapToolbar queryType="scheme" schemeType="burst_detection" hiddenButtons={["style"]} /> + <BurstDetectionPanel /> + </MapComponent> + </div> + ); +} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index ecd54d9..b2f81f5 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -18,13 +18,12 @@ import { ProjectProvider } from "@/contexts/ProjectContext"; import { useAuthStore } from "@/store/authStore"; import { LiaNetworkWiredSolid } from "react-icons/lia"; -import { TbDatabaseEdit, TbLocationPin } from "react-icons/tb"; +import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; import { AiOutlinePartition } from "react-icons/ai"; import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { - Analytics as AnalyticsIcon, MyLocation as MyLocationIcon, Search as SearchIcon, } from "@mui/icons-material"; @@ -193,6 +192,15 @@ const App = (props: React.PropsWithChildren<AppProps>) => { label: "爆管定位", }, }, + { + name: "爆管侦测", + list: "/hydraulic-simulation/burst-detection", + meta: { + parent: "Hydraulic Simulation", + icon: <TbActivity className="w-6 h-6" />, + label: "爆管侦测", + }, + }, { name: "DMA 漏损识别", list: "/hydraulic-simulation/dma-leak-detection", diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx new file mode 100644 index 0000000..96725b4 --- /dev/null +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -0,0 +1,471 @@ +"use client"; + +import React, { useMemo, useState, useCallback } from "react"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + Box, + Button, + CircularProgress, + Collapse, + FormControl, + MenuItem, + Select, + TextField, + Typography, + IconButton, +} from "@mui/material"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import { useNotification } from "@refinedev/core"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +import { api } from "@/lib/api"; +import { NETWORK_NAME, config } from "@config/config"; +import { BurstDetectionResult } from "./types"; + +interface Props { + onResult: (result: BurstDetectionResult) => void; +} + +interface SchemeItem { + scheme_id: number; + scheme_name: string; + scheme_type: string; + create_time: string; + scheme_start_time: string; + scheme_detail?: { + modify_total_duration: number; + }; +} + +const AnalysisParameters: React.FC<Props> = ({ onResult }) => { + const { open } = useNotification(); + const [schemeName, setSchemeName] = useState(`Burst_Detection_${Date.now()}`); + const [dataSource, setDataSource] = useState<"monitoring" | "simulation">("monitoring"); + const [schemes, setSchemes] = useState<SchemeItem[]>([]); + const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">(""); + const [schemeLoading, setSchemeLoading] = useState(false); + const [scadaStart, setScadaStart] = useState<Dayjs | null>(dayjs().subtract(3, "day")); + const [scadaEnd, setScadaEnd] = useState<Dayjs | null>(dayjs()); + const [mu, setMu] = useState<number>(100); + const [pointsPerDay, setPointsPerDay] = useState<number>(96); + const [nEstimators, setNEstimators] = useState<number>(50); + const [contaminationInput, setContaminationInput] = useState<string>("auto"); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [running, setRunning] = useState(false); + const isSimulationMode = dataSource === "simulation"; + + const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { + const start = dayjs(scheme.scheme_start_time); + const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; + const end = start.add(durationSeconds, "second"); + + setScadaStart(start); + setScadaEnd(end); + }, []); + + const fetchSchemes = useCallback( + async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { + if (schemeLoading || (!force && schemes.length > 0)) return; + + setSchemeLoading(true); + try { + const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, { + params: { network: NETWORK_NAME }, + }); + const burstSchemes = (response.data as SchemeItem[]).filter( + (scheme) => scheme.scheme_type === "burst_analysis", + ); + + setSchemes(burstSchemes); + + if (selectedSchemeId) { + const matchedScheme = burstSchemes.find( + (scheme) => scheme.scheme_id === selectedSchemeId, + ); + if (matchedScheme) { + applySchemeTimeRange(matchedScheme); + } else { + setSelectedSchemeId(""); + } + } + + if (notify) { + open?.({ + type: "success", + message: "方案列表已刷新", + description: `当前可选爆管分析方案 ${burstSchemes.length} 个`, + }); + } + } catch (error: any) { + open?.({ + type: "error", + message: "刷新方案失败", + description: + error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表", + }); + } finally { + setSchemeLoading(false); + } + }, + [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId], + ); + + const handleDataSourceChange = (value: "monitoring" | "simulation") => { + setDataSource(value); + if (value === "simulation") { + void fetchSchemes(); + } + }; + + const handleSchemeSelect = (schemeId: number) => { + setSelectedSchemeId(schemeId); + const scheme = schemes.find((item) => item.scheme_id === schemeId); + if (scheme) { + applySchemeTimeRange(scheme); + } + }; + + const timeWindowValid = useMemo(() => { + if (!scadaStart || !scadaEnd) return false; + return scadaEnd.diff(scadaStart, "day", true) >= 2; + }, [scadaEnd, scadaStart]); + + const contaminationValue = useMemo(() => { + const normalized = contaminationInput.trim().toLowerCase(); + if (!normalized || normalized === "auto") { + return "auto" as const; + } + const parsed = Number(normalized); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) { + return null; + } + return parsed; + }, [contaminationInput]); + + const isValid = + Boolean(scadaStart && scadaEnd) && + timeWindowValid && + Number.isFinite(mu) && + mu > 0 && + Number.isFinite(pointsPerDay) && + pointsPerDay > 0 && + Number.isFinite(nEstimators) && + nEstimators > 0 && + contaminationValue !== null && + (dataSource !== "simulation" || Boolean(selectedSchemeId)); + + const handleRun = async () => { + if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) { + open?.({ + type: "error", + message: "参数不完整", + description: "请检查时间范围(至少2天)和高级参数是否填写正确。", + }); + return; + } + + setRunning(true); + open?.({ + key: "burst-detection-analysis", + type: "progress", + message: "正在执行爆管侦测", + description: "正在读取数据并计算异常分数。", + undoableTimeout: 3, + }); + + try { + const selectedScheme = + dataSource === "simulation" + ? schemes.find((item) => item.scheme_id === selectedSchemeId) + : undefined; + + const response = await api.post("/api/v1/burst-detection/detect/", { + network: NETWORK_NAME, + data_source: dataSource, + scheme_name: schemeName.trim() || undefined, + scada_start: scadaStart.toISOString(), + scada_end: scadaEnd.toISOString(), + mu, + points_per_day: pointsPerDay, + iforest_params: { + n_estimators: nEstimators, + contamination: contaminationValue, + }, + simulation_scheme_name: selectedScheme?.scheme_name, + simulation_scheme_type: selectedScheme?.scheme_type, + }); + + onResult({ + ...(response.data as BurstDetectionResult), + scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name, + algorithm_params: { + mu, + points_per_day: pointsPerDay, + iforest_params: { + n_estimators: nEstimators, + contamination: contaminationValue, + }, + }, + }); + + open?.({ + key: "burst-detection-analysis", + type: "success", + message: "爆管侦测完成", + description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, + }); + } catch (error: any) { + open?.({ + key: "burst-detection-analysis", + type: "error", + message: "侦测失败", + description: error?.response?.data?.detail ?? error?.message ?? "请求失败", + }); + } finally { + setRunning(false); + } + }; + + return ( + <Box className="flex flex-col flex-1 min-h-0"> + <Box className="flex flex-col gap-3"> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 方案名称 + </Typography> + <TextField + value={schemeName} + onChange={(event) => setSchemeName(event.target.value)} + placeholder="请输入方案名称" + fullWidth + size="small" + /> + </Box> + + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 数据来源 + </Typography> + <FormControl fullWidth size="small"> + <Select + value={dataSource} + onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")} + > + <MenuItem value="monitoring">监测数据</MenuItem> + <MenuItem value="simulation">模拟方案</MenuItem> + </Select> + </FormControl> + </Box> + + {isSimulationMode && ( + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 选择爆管分析方案 + </Typography> + <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> + <FormControl fullWidth size="small"> + <Select + value={selectedSchemeId} + onChange={(e) => handleSchemeSelect(Number(e.target.value))} + disabled={schemeLoading} + displayEmpty + > + <MenuItem value="" disabled> + 请选择方案 + </MenuItem> + {schemes.map((scheme) => ( + <MenuItem key={scheme.scheme_id} value={scheme.scheme_id}> + {scheme.scheme_name} + </MenuItem> + ))} + </Select> + </FormControl> + <IconButton + size="small" + color="primary" + onClick={() => void fetchSchemes({ force: true, notify: true })} + disabled={schemeLoading} + aria-label="刷新爆管分析方案" + sx={{ + border: "1px solid", + borderColor: "divider", + borderRadius: 1, + }} + > + {schemeLoading ? ( + <CircularProgress size={18} color="inherit" /> + ) : ( + <RefreshIcon fontSize="small" /> + )} + </IconButton> + </Box> + </Box> + )} + + <LocalizationProvider + dateAdapter={AdapterDayjs} + adapterLocale="zh-cn" + localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText} + > + <Box className="grid grid-cols-2 gap-2"> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 侦测开始时间 + </Typography> + <DateTimePicker + value={scadaStart} + onChange={setScadaStart} + maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined} + disabled={isSimulationMode} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 侦测结束时间 + </Typography> + <DateTimePicker + value={scadaEnd} + onChange={setScadaEnd} + minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined} + disabled={isSimulationMode} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + </Box> + </Box> + </LocalizationProvider> + + <Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900"> + 当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。 + </Box> + + <Box + sx={{ + border: "1px solid", + borderColor: "grey.200", + borderRadius: 1, + overflow: "hidden", + }} + > + <Box + role="button" + tabIndex={0} + onClick={() => setAdvancedOpen((prev) => !prev)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + setAdvancedOpen((prev) => !prev); + } + }} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + px: 1.25, + py: 0.75, + cursor: "pointer", + backgroundColor: "transparent", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + <Typography variant="body2" color="text.secondary"> + 高级参数 + </Typography> + <ExpandMoreIcon + sx={{ + transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)", + transition: "transform 0.2s ease", + }} + /> + </Box> + <Collapse in={advancedOpen} timeout="auto" unmountOnExit> + <Box + sx={{ + px: 1.25, + pt: 1.25, + pb: 1.25, + backgroundColor: "transparent", + }} + > + <Box className="flex flex-col gap-3"> + <TextField + type="number" + label="频域截断系数" + value={mu} + onChange={(event) => setMu(Number(event.target.value))} + size="small" + fullWidth + inputProps={{ min: 1 }} + /> + <TextField + type="number" + label="每日采样点数" + value={pointsPerDay} + onChange={(event) => setPointsPerDay(Number(event.target.value))} + size="small" + fullWidth + inputProps={{ min: 1 }} + /> + <TextField + type="number" + label="孤立森林树数量" + value={nEstimators} + onChange={(event) => setNEstimators(Number(event.target.value))} + size="small" + fullWidth + inputProps={{ min: 1 }} + /> + <TextField + label="异常比例" + value={contaminationInput} + onChange={(event) => setContaminationInput(event.target.value)} + size="small" + fullWidth + helperText="填写 auto 或 0~0.5 之间的小数。" + error={contaminationValue === null} + /> + </Box> + </Box> + </Collapse> + </Box> + </Box> + + <Box className="mt-auto pt-3 flex gap-2"> + <Button + variant="outlined" + fullWidth + disabled={running} + sx={{ textTransform: "none", fontWeight: 500 }} + onClick={() => { + setSchemeName(`Burst_Detection_${Date.now()}`); + setScadaStart(dayjs().subtract(3, "day")); + setScadaEnd(dayjs()); + setMu(100); + setPointsPerDay(96); + setNEstimators(50); + setContaminationInput("auto"); + }} + > + 重置 + </Button> + <Button + variant="contained" + fullWidth + disabled={!isValid || running} + onClick={handleRun} + className="bg-blue-600 hover:bg-blue-700" + sx={{ textTransform: "none", fontWeight: 500 }} + > + {running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"} + </Button> + </Box> + </Box> + ); +}; + +export default AnalysisParameters; diff --git a/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx new file mode 100644 index 0000000..d85182b --- /dev/null +++ b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx @@ -0,0 +1,153 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { Box, Drawer, IconButton, Tab, Tabs, Tooltip, Typography } from "@mui/material"; +import { + Analytics as AnalyticsIcon, + ChevronLeft, + ChevronRight, + FormatListBulleted, + Search as SearchIcon, +} from "@mui/icons-material"; +import AnalysisParameters from "./AnalysisParameters"; +import DetectionResults from "./DetectionResults"; +import SchemeQuery from "./SchemeQuery"; +import { BurstDetectionResult } from "./types"; + +const TabPanel = ({ + value, + index, + children, +}: { + value: number; + index: number; + children: React.ReactNode; +}) => ( + <div role="tabpanel" hidden={value !== index} className="flex-1 overflow-hidden flex flex-col"> + {value === index ? <Box className="flex-1 overflow-auto p-4 flex flex-col">{children}</Box> : null} + </div> +); + +const BurstDetectionPanel: React.FC = () => { + const [open, setOpen] = useState(true); + const [tab, setTab] = useState(0); + const [result, setResult] = useState<BurstDetectionResult | null>(null); + + const drawerWidth = 450; + const panelTitle = "爆管侦测"; + + const handleResult = useCallback((payload: BurstDetectionResult) => { + setResult(payload); + setTab(2); + }, []); + + return ( + <> + {!open && ( + <Box + className="absolute top-4 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100" + onClick={() => setOpen(true)} + sx={{ zIndex: 1300 }} + > + <Box className="flex flex-col items-center py-3 px-3 gap-1"> + <AnalyticsIcon className="text-[#257DD4] w-5 h-5" /> + <Typography + variant="caption" + className="text-gray-700 font-semibold my-1 text-xs" + style={{ writingMode: "vertical-rl" }} + > + {panelTitle} + </Typography> + <ChevronLeft className="text-gray-600 w-4 h-4" /> + </Box> + </Box> + )} + + <Drawer + anchor="right" + open={open} + variant="persistent" + hideBackdrop + sx={{ + width: 0, + flexShrink: 0, + "& .MuiDrawer-paper": { + width: drawerWidth, + boxSizing: "border-box", + position: "absolute", + top: 16, + right: 16, + height: "calc(100vh - 32px)", + maxHeight: "850px", + borderRadius: "12px", + boxShadow: + "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", + backdropFilter: "blur(8px)", + opacity: 0.95, + transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out", + border: "none", + "&:hover": { + opacity: 1, + }, + }, + }} + > + <Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden"> + <Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white"> + <Box className="flex items-center gap-2"> + <AnalyticsIcon className="w-5 h-5" /> + <Typography variant="h6" className="text-lg font-semibold"> + {panelTitle} + </Typography> + </Box> + <Tooltip title="收起"> + <IconButton size="small" onClick={() => setOpen(false)} sx={{ color: "primary.contrastText" }}> + <ChevronRight fontSize="small" /> + </IconButton> + </Tooltip> + </Box> + + <Box className="border-b border-gray-200 bg-white"> + <Tabs + value={tab} + onChange={(_, value) => setTab(value)} + variant="fullWidth" + sx={{ + minHeight: 48, + "& .MuiTab-root": { + minHeight: 48, + textTransform: "none", + fontSize: "0.875rem", + fontWeight: 500, + transition: "all 0.2s", + }, + "& .Mui-selected": { + color: "#257DD4", + }, + "& .MuiTabs-indicator": { + backgroundColor: "#257DD4", + }, + }} + > + <Tab icon={<AnalyticsIcon fontSize="small" />} iconPosition="start" label="侦测参数" /> + <Tab icon={<SearchIcon fontSize="small" />} iconPosition="start" label="方案查询" /> + <Tab icon={<FormatListBulleted fontSize="small" />} iconPosition="start" label="侦测结果" /> + </Tabs> + </Box> + + <TabPanel value={tab} index={0}> + <AnalysisParameters onResult={handleResult} /> + </TabPanel> + <TabPanel value={tab} index={1}> + <SchemeQuery onViewResult={handleResult} /> + </TabPanel> + <TabPanel value={tab} index={2}> + <DetectionResults result={result} /> + </TabPanel> + </Box> + </Drawer> + </> + ); +}; + +export default BurstDetectionPanel; diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx new file mode 100644 index 0000000..6b9d914 --- /dev/null +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -0,0 +1,610 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Button, Chip, Tooltip, Typography } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { zhCN } from "@mui/x-data-grid/locales"; +import { + FormatListBulleted, + InfoOutlined as InfoOutlinedIcon, + Room as RoomIcon, + ShowChart as ShowChartIcon, + CheckCircleOutline as CheckCircleIcon, + ErrorOutline as ErrorOutlineIcon, +} from "@mui/icons-material"; +import ReactECharts from "echarts-for-react"; +import dayjs from "dayjs"; +import { useMap } from "@components/olmap/core/MapComponent"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { GeoJSON } from "ol/format"; +import Feature from "ol/Feature"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Circle, Fill, Stroke, Style } from "ol/style"; +import { bbox, featureCollection } from "@turf/turf"; +import { BurstDetectionResult, BurstDetectionRow } from "./types"; + +interface Props { + result: BurstDetectionResult | null; +} + +interface MetricCardProps { + label: string; + value: string; + hint?: string; + tone: "blue" | "orange" | "purple" | "green"; +} + +const toneStyles: Record< + MetricCardProps["tone"], + { bg: string; border: string; text: string; darkText: string } +> = { + blue: { + bg: "from-blue-50 to-blue-100", + border: "border-blue-200", + text: "text-blue-700", + darkText: "text-blue-900", + }, + orange: { + bg: "from-orange-50 to-orange-100", + border: "border-orange-200", + text: "text-orange-700", + darkText: "text-orange-900", + }, + purple: { + bg: "from-purple-50 to-purple-100", + border: "border-purple-200", + text: "text-purple-700", + darkText: "text-purple-900", + }, + green: { + bg: "from-green-50 to-green-100", + border: "border-green-200", + text: "text-green-700", + darkText: "text-green-900", + }, +}; + +const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { + const style = toneStyles[tone]; + return ( + <Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}> + <Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}> + {label} + </Typography> + <Typography variant="body2" className={`font-bold ${style.darkText}`}> + {value} + </Typography> + {hint ? ( + <Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}> + {hint} + </Typography> + ) : null} + </Box> + ); +}; + +const EmptyState = () => ( + <Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> + <Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> + <ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> + </Box> + <Typography variant="h6" className="mb-1 font-bold text-gray-700"> + 等待侦测结果 + </Typography> + <Typography variant="body2" className="max-w-xs text-gray-500"> + 提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。 + </Typography> + </Box> +); + +const getScoreLevel = (score: number) => { + if (score <= -0.6) return { label: "高风险", color: "error" as const }; + if (score <= -0.2) return { label: "需关注", color: "warning" as const }; + return { label: "正常", color: "success" as const }; +}; + +const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"); + +const DetectionResults: React.FC<Props> = ({ result }) => { + const map = useMap(); + const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); + const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); + const [selectedDay, setSelectedDay] = useState<number | null>(null); + + useEffect(() => { + if (!map) return; + + const layer = new VectorLayer({ + source: new VectorSource(), + style: new Style({ + stroke: new Stroke({ color: "#ef4444", width: 4 }), + image: new Circle({ + radius: 7, + fill: new Fill({ color: "#ef4444" }), + stroke: new Stroke({ color: "#fff", width: 2 }), + }), + zIndex: 999, + }), + properties: { + name: "爆管侦测高亮", + value: "burst_detection_highlight", + }, + }); + + map.addLayer(layer); + highlightLayerRef.current = layer; + + return () => { + highlightLayerRef.current = null; + map.removeLayer(layer); + }; + }, [map]); + + useEffect(() => { + const source = highlightLayerRef.current?.getSource(); + if (!source) return; + source.clear(); + highlightFeatures.forEach((feature) => source.addFeature(feature)); + }, [highlightFeatures]); + + const defaultSelectedDay = useMemo( + () => + result?.summary?.most_anomalous_day ?? + result?.summary?.latest_day?.Day ?? + result?.rows[0]?.Day ?? + null, + [result], + ); + + const activeSelectedDay = selectedDay ?? defaultSelectedDay; + + const selectedRow = useMemo<BurstDetectionRow | null>(() => { + if (!result || activeSelectedDay === null) return null; + return result.rows.find((row) => row.Day === activeSelectedDay) ?? null; + }, [activeSelectedDay, result]); + + const scoreSeries = useMemo( + () => + result?.rows.map((row) => ({ + value: [row.Day, Number(row.Score.toFixed(4))], + itemStyle: { + color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981", + }, + })) ?? [], + [result], + ); + + const rankingSeries = useMemo( + () => + [...(result?.summary?.latest_sensor_rankings ?? [])] + .sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value) + .map((item) => ({ + name: item.sensor_node, + value: Number(item.latest_high_frequency_value.toFixed(4)), + })), + [result], + ); + + const locateSensors = async (sensorIds: string[]) => { + if (!map || sensorIds.length === 0) return; + + let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat"); + if (features.length === 0) { + features = await queryFeaturesByIds(sensorIds, "geo_junctions"); + } + if (features.length === 0) return; + + setHighlightFeatures(features); + + const geojsonFormat = new GeoJSON(); + const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); + // @ts-ignore turf typing with ol geojson objects + const extent = bbox(featureCollection(geojsonFeatures)); + map.getView().fit(extent, { + maxZoom: 18, + duration: 1000, + padding: [100, 100, 100, 100], + }); + }; + + if (!result) { + return <EmptyState />; + } + + const latestDay = result.summary?.latest_day; + const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0); + const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null; + const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0); + const isBurstDetected = result.summary.burst_detected; + + const chartOption = { + tooltip: { + trigger: "axis", + formatter: (params: Array<{ data: { value: [number, number] } }>) => { + const point = params[0]?.data?.value; + if (!point) return "-"; + return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`; + }, + }, + grid: { top: 30, left: 40, right: 20, bottom: 35 }, + xAxis: { + type: "category", + name: "侦测日", + data: result.rows.map((row) => row.Day), + axisLabel: { fontSize: 10 }, + }, + yAxis: { + type: "value", + name: "异常分数", + axisLabel: { fontSize: 10 }, + }, + series: [ + { + type: "line", + smooth: true, + symbolSize: 8, + data: scoreSeries, + lineStyle: { color: "#2563eb", width: 2 }, + markLine: { + symbol: "none", + lineStyle: { type: "dashed", color: "#94a3b8" }, + data: [{ yAxis: 0 }], + }, + }, + ], + }; + + const rankingOption = { + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + }, + grid: { top: 20, left: 70, right: 20, bottom: 20 }, + xAxis: { type: "value", axisLabel: { fontSize: 10 } }, + yAxis: { + type: "category", + data: rankingSeries.map((item) => item.name), + axisLabel: { fontSize: 10 }, + }, + series: [ + { + type: "bar", + data: rankingSeries.map((item) => ({ + value: item.value, + itemStyle: { + color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981", + }, + })), + barWidth: 14, + }, + ], + }; + + const columns: GridColDef[] = [ + { + field: "Day", + headerName: "侦测日", + width: 96, + valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"), + }, + { + field: "Score", + headerName: "异常分数", + width: 120, + valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"), + }, + { + field: "IsBurst", + headerName: "判定结果", + width: 120, + renderCell: ({ value }) => { + const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const }; + return <Chip size="small" label={level.label} color={level.color} variant="outlined" />; + }, + }, + ]; + + const rows = result.rows.map((row) => ({ id: row.Day, ...row })); + + return ( + <Box className="h-full overflow-auto p-1"> + <Box className="mb-4 space-y-3"> + {/* Status Banner */} + <Box + className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected + ? "bg-red-50 border-red-100 text-red-900" + : "bg-green-50 border-green-100 text-green-900" + }`} + > + {isBurstDetected ? ( + <ErrorOutlineIcon className="text-red-600" /> + ) : ( + <CheckCircleIcon className="text-green-600" /> + )} + <Box className="flex-1"> + <Typography variant="subtitle2" className="font-bold"> + {isBurstDetected + ? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)` + : "未侦测到爆管异常"} + </Typography> + <Typography variant="caption" className="opacity-80"> + {isBurstDetected + ? "建议检查异常日期的压力波动情况" + : "当前时间窗口内数据特征平稳,符合历史模式"} + </Typography> + </Box> + </Box> + + {/* Header */} + <Box className="flex items-center justify-between px-1"> + <Box className="flex items-center gap-2"> + <Box className="h-4 w-1 rounded-full bg-blue-600" /> + <Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}> + {result.scheme_name || "爆管侦测结果"} + </Typography> + </Box> + <Box className="flex items-center gap-2"> + {result.username ? ( + <Chip + label={result.username} + size="small" + sx={{ + height: 24, + backgroundColor: "#f3f4f6", + color: "#4b5563", + border: "none", + fontWeight: 500, + }} + /> + ) : null} + <Button + size="small" + variant="outlined" + startIcon={<RoomIcon />} + onClick={() => + locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5)) + } + sx={{ + height: 24, + minWidth: 0, + padding: "0 8px", + borderColor: "#bfdbfe", + color: "#2563eb", + fontSize: "0.75rem", + "&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" }, + }} + > + 定位 + </Button> + </Box> + </Box> + + {/* Configuration Summary */} + <Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600"> + <Box className="flex items-center gap-1.5"> + <Box className="h-1.5 w-1.5 rounded-full bg-blue-400" /> + <span className="font-medium text-gray-700">时间窗口:</span> + <span className="font-mono text-gray-600"> + {formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)} + </span> + </Box> + <Box className="flex items-center gap-1.5"> + <Box className="h-1.5 w-1.5 rounded-full bg-purple-400" /> + <span className="font-medium text-gray-700">数据来源:</span> + <span className="text-gray-600"> + {(() => { + const ds = result.data_source; + const os = result.observed_source; + if (ds === "simulation") return "模拟数据"; + if (ds === "monitoring") return "监测数据"; + if (os === "simulation_scheme_timerange") return "模拟数据"; + if (os === "backend_timerange") return "监测数据"; + return os || "-"; + })()} + </span> + </Box> + </Box> + + {/* Metrics Grid */} + <Box className="grid grid-cols-2 gap-3"> + <MetricCard + label="异常天数" + value={`${result.summary.anomaly_day_count} / ${result.day_count}`} + hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`} + tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"} + /> + <MetricCard + label="最异常日" + value={ + result.summary.burst_detected && result.summary.most_anomalous_day + ? `第 ${result.summary.most_anomalous_day} 天` + : "无" + } + hint={ + result.summary.burst_detected && mostAnomalousRow + ? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}` + : "-" + } + tone="purple" + /> + <MetricCard + label="最新状态" + value={latestLevel.label} + hint={latestDay ? `第 ${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"} + tone={latestLevel.color === "success" ? "green" : "orange"} + /> + <MetricCard + label="测点 / 样本" + value={`${result.sensor_nodes.length} / ${result.sample_count}`} + hint={`每日采样点数:${result.points_per_day}`} + tone="blue" + /> + </Box> + </Box> + + {/* Score Trend Chart */} + <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> + <Box className="flex items-center gap-2"> + <ShowChartIcon className="h-5 w-5 text-blue-600" /> + <Typography variant="subtitle1" className="font-bold text-gray-800"> + 异常分数趋势 + </Typography> + </Box> + <Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。"> + <InfoOutlinedIcon fontSize="small" className="text-gray-400" /> + </Tooltip> + </Box> + <Box sx={{ height: 250, px: 1.5, py: 1 }}> + <ReactECharts + option={chartOption} + style={{ height: "100%", width: "100%" }} + onEvents={{ + click: (params: { data?: { value?: [number, number] } }) => { + const day = params?.data?.value?.[0]; + if (typeof day === "number") { + setSelectedDay(day); + } + }, + }} + /> + </Box> + </Box> + + {/* Selected Day Interpretation */} + <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> + <Typography variant="subtitle1" className="font-bold text-gray-800"> + 选中日解读 + </Typography> + {selectedRow ? ( + <Chip + size="small" + label={`第 ${selectedRow.Day} 天`} + sx={{ + height: 22, + backgroundColor: "rgba(37, 99, 235, 0.08)", + color: "#2563eb", + fontWeight: 600, + fontSize: "0.75rem", + border: "none", + }} + /> + ) : null} + </Box> + {selectedRow ? ( + <Box className="space-y-3 px-4 py-3"> + <Box className="flex items-center gap-2"> + <Chip + label={getScoreLevel(selectedRow.Score).label} + color={getScoreLevel(selectedRow.Score).color} + variant="filled" + /> + </Box> + <Typography variant="body2" className="text-gray-700"> + 异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span> + </Typography> + <Typography variant="body2" className="text-gray-700"> + 模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1)" : "正常日(Prediction = 1)"} + </Typography> + <Typography variant="body2" className="text-gray-700"> + 解读建议: + {selectedRow.Score <= -0.6 + ? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。" + : selectedRow.Score <= -0.2 + ? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。" + : "未见明显异常,可作为基线日参考。"} + </Typography> + </Box> + ) : ( + <Typography variant="body2" className="px-4 py-3 text-gray-500"> + 请在趋势图或表格中选择一天查看详细解释。 + </Typography> + )} + </Box> + + {/* Latest Sensor Rankings */} + <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> + <Typography variant="subtitle1" className="font-bold text-gray-800"> + 最新测点高频特征排名 + </Typography> + <Typography variant="caption" className="text-gray-500"> + 仅展示最新一天 + </Typography> + </Box> + <Box sx={{ height: 260, px: 1.5, py: 1 }}> + <ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} /> + </Box> + <Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3"> + {result.summary.latest_sensor_rankings.slice(0, 5).map((item) => ( + <Button + key={item.sensor_node} + size="small" + variant="outlined" + onClick={() => locateSensors([item.sensor_node])} + sx={{ + borderColor: "#bfdbfe", + color: "#2563eb", + "&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" }, + }} + > + {item.sensor_node} + </Button> + ))} + </Box> + </Box> + + {/* Results Table */} + <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> + <Box className="flex items-center gap-2"> + <FormatListBulleted className="h-5 w-5 text-blue-600" /> + <Typography variant="subtitle1" className="font-bold text-gray-800"> + 结果表格 + </Typography> + </Box> + <Chip + size="small" + label={`${rows.length} 条`} + sx={{ + height: 22, + backgroundColor: "rgba(37, 99, 235, 0.08)", + color: "#2563eb", + fontWeight: 600, + fontSize: "0.75rem", + border: "none", + }} + /> + </Box> + <Box sx={{ height: 320, px: 1, py: 1 }}> + <DataGrid + rows={rows} + columns={columns} + columnBufferPx={100} + localeText={zhCN.components.MuiDataGrid.defaultProps.localeText} + initialState={{ + pagination: { paginationModel: { pageSize: 50, page: 0 } }, + }} + pageSizeOptions={[50]} + hideFooterSelectedRowCount + sx={{ + border: "none", + "& .MuiDataGrid-cell": { borderColor: "#f0f0f0" }, + "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" }, + "& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" }, + // Hide the rows per page selector since it's fixed to 50 + "& .MuiTablePagination-selectLabel": { display: "none" }, + "& .MuiTablePagination-input": { display: "none" }, + }} + disableRowSelectionOnClick + onRowClick={(params) => setSelectedDay(Number(params.row.Day))} + /> + </Box> + </Box> + </Box> + ); +}; + +export default DetectionResults; diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx new file mode 100644 index 0000000..a00eaec --- /dev/null +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -0,0 +1,350 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + Collapse, + FormControlLabel, + IconButton, + Tooltip, + Typography, +} from "@mui/material"; +import { InfoOutlined as InfoIcon } from "@mui/icons-material"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import "dayjs/locale/zh-cn"; +import { useNotification } from "@refinedev/core"; +import { api } from "@/lib/api"; +import { NETWORK_NAME } from "@config/config"; +import { + BurstDetectionResult, + BurstDetectionSchemeDetail, + BurstDetectionSchemeRecord, +} from "./types"; + +interface Props { + onViewResult: (result: BurstDetectionResult) => void; +} + +const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { + const { open } = useNotification(); + const [queryAll, setQueryAll] = useState(true); + const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); + const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]); + const [loading, setLoading] = useState(false); + const [expandedId, setExpandedId] = useState<number | null>(null); + + const buildDisplayResult = ( + scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">, + detail?: BurstDetectionSchemeDetail, + ): BurstDetectionResult | null => { + const payload = detail?.result_payload; + const summary = detail?.result_summary; + const fallbackLatestDay = summary?.latest_day; + + if (!payload && !summary) return null; + + return { + network: payload?.network ?? detail?.network ?? NETWORK_NAME, + sensor_nodes: payload?.sensor_nodes ?? detail?.sensor_nodes ?? [], + observed_source: payload?.observed_source ?? detail?.observed_source ?? "stored_scheme", + sample_count: payload?.sample_count ?? 0, + points_per_day: payload?.points_per_day ?? detail?.algorithm_params?.points_per_day ?? 1440, + day_count: payload?.day_count ?? payload?.rows?.length ?? 0, + rows: payload?.rows ?? (fallbackLatestDay ? [fallbackLatestDay] : []), + summary: + payload?.summary ?? + (summary + ? summary + : { + burst_detected: false, + latest_day: fallbackLatestDay ?? { Day: 0, Score: 0, Prediction: 1, IsBurst: false }, + most_anomalous_day: 0, + anomaly_days: [], + anomaly_day_count: 0, + latest_sensor_rankings: [], + }), + scada_window: payload?.scada_window ?? detail?.scada_window, + scheme_name: payload?.scheme_name ?? scheme.scheme_name, + username: payload?.username ?? scheme.username, + create_time: payload?.create_time ?? scheme.create_time, + algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params, + }; + }; + + const handleQuery = async () => { + setLoading(true); + try { + const params: Record<string, string> = { network: NETWORK_NAME }; + if (!queryAll && queryDate) { + params.query_date = queryDate.startOf("day").toISOString(); + } + + const response = await api.get("/api/v1/burst-detection/schemes/", { params }); + setSchemes(response.data); + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${response.data.length} 条侦测记录。`, + }); + } catch (error: any) { + open?.({ + type: "error", + message: "查询失败", + description: error?.response?.data?.detail ?? "无法获取侦测方案列表", + }); + } finally { + setLoading(false); + } + }; + + const handleViewSchemeResult = async (schemeName: string) => { + try { + const response = await api.get( + `/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`, + { params: { network: NETWORK_NAME } }, + ); + const schemeRecord = response.data as BurstDetectionSchemeRecord & { + result_payload?: BurstDetectionResult; + }; + const normalizedResult = + schemeRecord.result_payload ?? + buildDisplayResult( + { + scheme_name: schemeRecord.scheme_name, + username: schemeRecord.username, + create_time: schemeRecord.create_time, + }, + schemeRecord.scheme_detail, + ); + + if (!normalizedResult) { + throw new Error("方案详情缺少侦测结果数据"); + } + + onViewResult(normalizedResult); + open?.({ + type: "success", + message: "方案加载成功", + description: `已加载方案:${schemeName}`, + }); + } catch (error: any) { + open?.({ + type: "error", + message: "查看详情失败", + description: error?.response?.data?.detail ?? error?.message ?? "无法获取方案详情", + }); + } + }; + + return ( + <Box className="flex h-full flex-col"> + <Box className="mb-2 rounded bg-gray-50 p-2"> + <Box className="flex items-center justify-between gap-2"> + <Box className="flex items-center gap-2"> + <FormControlLabel + control={ + <Checkbox + size="small" + checked={queryAll} + onChange={(event) => setQueryAll(event.target.checked)} + /> + } + label={<Typography variant="body2">查询全部</Typography>} + className="m-0" + /> + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> + <DatePicker + value={queryDate} + onChange={setQueryDate} + disabled={queryAll} + format="YYYY-MM-DD" + slotProps={{ textField: { size: "small", sx: { width: 180 } } }} + /> + </LocalizationProvider> + </Box> + <Button + variant="contained" + onClick={handleQuery} + disabled={loading} + size="small" + className="bg-blue-600 hover:bg-blue-700" + sx={{ minWidth: 80 }} + > + {loading ? "查询中..." : "查询"} + </Button> + </Box> + </Box> + + <Box className="flex-1 overflow-auto"> + {schemes.length === 0 ? ( + <Box className="flex h-full flex-col items-center justify-center text-center text-gray-400"> + <Typography variant="body2">暂无侦测方案</Typography> + <Typography variant="caption" className="mt-1"> + 运行一次展示版侦测后,可在这里回看历史结果。 + </Typography> + </Box> + ) : ( + <Box className="space-y-2 p-2"> + <Typography variant="caption" className="px-2 text-gray-500"> + 共 {schemes.length} 条记录 + </Typography> + {schemes.map((scheme) => { + const summary = scheme.scheme_detail?.result_summary; + const payload = scheme.scheme_detail?.result_payload; + const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false; + const anomalyDayCount = + payload?.summary?.anomaly_day_count ?? summary?.anomaly_day_count ?? 0; + const mostAnomalousDay = + payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-"; + const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0; + + return ( + <Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md"> + <CardContent className="p-3 pb-2 last:pb-3"> + <Box className="mb-2 flex items-start justify-between gap-2"> + <Box className="min-w-0 flex-1"> + <Box className="mb-1 flex items-center gap-2"> + <Typography + variant="body2" + className="truncate font-medium" + title={scheme.scheme_name} + > + {scheme.scheme_name} + </Typography> + <Chip + size="small" + color={isBurst ? "error" : "success"} + variant="outlined" + label={isBurst ? "存在异常" : "正常"} + className="h-5" + /> + </Box> + <Typography variant="caption" className="block text-gray-500"> + 创建时间:{dayjs(scheme.create_time).format("YYYY-MM-DD HH:mm")} + </Typography> + </Box> + <Box className="ml-2 flex gap-1"> + <Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}> + <IconButton + size="small" + onClick={() => + setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id) + } + color="primary" + className="p-1" + > + <InfoIcon fontSize="small" /> + </IconButton> + </Tooltip> + </Box> + </Box> + + <Box className="grid grid-cols-3 gap-2"> + <Box className="rounded bg-gray-50 p-2"> + <Typography variant="caption" className="text-gray-500"> + 异常天数 + </Typography> + <Typography variant="body2" className="font-semibold text-gray-900"> + {anomalyDayCount} + </Typography> + </Box> + <Box className="rounded bg-gray-50 p-2"> + <Typography variant="caption" className="text-gray-500"> + 最异常日 + </Typography> + <Typography variant="body2" className="font-semibold text-gray-900"> + {isBurst + ? typeof mostAnomalousDay === "number" + ? `第 ${mostAnomalousDay} 天` + : mostAnomalousDay + : "无"} + </Typography> + </Box> + <Box className="rounded bg-gray-50 p-2"> + <Typography variant="caption" className="text-gray-500"> + 测点数 + </Typography> + <Typography variant="body2" className="font-semibold text-gray-900"> + {sensorCount} + </Typography> + </Box> + </Box> + + <Collapse in={expandedId === scheme.scheme_id}> + <Box className="mt-2 border-t border-gray-200 pt-3"> + <Box className="space-y-2 rounded-md bg-gray-50 px-3 py-2"> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 数据来源: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {(() => { + const ds = payload?.data_source; + const os = payload?.observed_source ?? scheme.scheme_detail?.observed_source; + if (ds === "simulation") return "模拟数据"; + if (ds === "monitoring") return "监测数据"; + if (os === "simulation_scheme_timerange") return "模拟数据"; + if (os === "backend_timerange") return "监测数据"; + return os || "-"; + })()} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 时间窗口: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + {payload?.scada_window?.start + ? `${dayjs(payload.scada_window.start).format("MM-DD HH:mm")} ~ ${dayjs( + payload.scada_window.end, + ).format("MM-DD HH:mm")}` + : "-"} + </Typography> + </Box> + <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> + <Typography variant="caption" className="text-gray-600"> + 算法参数: + </Typography> + <Typography variant="caption" className="font-medium text-gray-900"> + 频域截断系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} + ,每日采样点数: + {scheme.scheme_detail?.algorithm_params?.points_per_day ?? + payload?.algorithm_params?.points_per_day ?? + "-"} + </Typography> + </Box> + </Box> + <Box className="border-t border-gray-100 pt-2"> + <Button + variant="contained" + fullWidth + size="small" + className="bg-blue-600 hover:bg-blue-700" + sx={{ textTransform: "none", fontWeight: 500 }} + onClick={() => handleViewSchemeResult(scheme.scheme_name)} + > + 查看侦测结果 + </Button> + </Box> + </Box> + </Collapse> + </CardContent> + </Card> + ); + })} + </Box> + )} + </Box> + </Box> + ); +}; + +export default SchemeQuery; diff --git a/src/components/olmap/BurstDetection/types.ts b/src/components/olmap/BurstDetection/types.ts new file mode 100644 index 0000000..235edac --- /dev/null +++ b/src/components/olmap/BurstDetection/types.ts @@ -0,0 +1,77 @@ +export interface BurstDetectionRow { + Day: number; + Score: number; + Prediction: number; + IsBurst: boolean; +} + +export interface BurstDetectionSensorRanking { + sensor_node: string; + latest_high_frequency_value: number; +} + +export interface BurstDetectionSummary { + burst_detected: boolean; + latest_day: BurstDetectionRow; + most_anomalous_day: number; + anomaly_days: number[]; + anomaly_day_count: number; + latest_sensor_rankings: BurstDetectionSensorRanking[]; +} + +export interface BurstDetectionAlgorithmParams { + mu?: number; + points_per_day?: number; + iforest_params?: { + n_estimators?: number; + contamination?: number | "auto"; + random_state?: number; + }; +} + +export interface BurstDetectionResult { + network: string; + sensor_nodes: string[]; + observed_source: string; + sample_count: number; + points_per_day: number; + day_count: number; + rows: BurstDetectionRow[]; + summary: BurstDetectionSummary; + scada_window?: { + start?: string; + end?: string; + }; + scheme_name?: string; + username?: string; + create_time?: string; + data_source?: "monitoring" | "simulation"; + simulation_scheme?: { + name?: string; + type?: string; + }; + algorithm_params?: BurstDetectionAlgorithmParams; +} + +export interface BurstDetectionSchemeDetail { + network?: string; + sensor_nodes?: string[]; + observed_source?: string; + scada_window?: { + start?: string; + end?: string; + }; + algorithm_params?: BurstDetectionAlgorithmParams; + result_summary?: BurstDetectionSummary; + result_payload?: BurstDetectionResult; +} + +export interface BurstDetectionSchemeRecord { + scheme_id: number; + scheme_name: string; + scheme_type?: string; + create_time: string; + scheme_start_time?: string; + username?: string; + scheme_detail?: BurstDetectionSchemeDetail; +} -- 2.54.0 From a7f4867afe50b4d395a568bf53ea8141cfd3c863 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Wed, 11 Mar 2026 17:50:03 +0800 Subject: [PATCH 047/281] =?UTF-8?q?=E7=94=9F=E6=88=90agent=20instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 208 ++++++++------------------------ 1 file changed, 47 insertions(+), 161 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a8cc01f..0fea55e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,174 +1,60 @@ -# Copilot Instructions for TJWater Frontend +# Copilot Instructions for TJWaterFrontend_Refine -## Project Overview +## Environment Setup -A Next.js 16 + TypeScript water network management system built with Refine framework, featuring real-time hydraulic simulation, SCADA data management, and GIS visualization using OpenLayers and Deck.gl. +1. **Node.js**: Ensure you have Node.js v18 or later installed. +2. **Dependencies**: Run `npm install` to install all project dependencies. +3. **Environment Variables**: Create a `.env.local` file in the root directory with -## Build, Test, and Lint Commands +Using bash setup dependencies: ```bash -# Development -npm run dev # Start dev server (uses 4GB memory allocation) - -# Production -npm run build # Build for production (standalone output) -npm run start # Start production server - -# Testing -npm run test # Run all tests -npm run test:watch # Run tests in watch mode -npm run test:coverage # Generate coverage report - -# Linting -npm run lint # Run ESLint +npm install ``` -**Run single test file:** +## Build, Test, and Lint -```bash -npm test -- path/to/test-file.test.ts -``` +- **Dev Server**: `npm run dev` (Runs with increased memory limit: `--max_old_space_size=4096`) +- **Build**: `npm run build` +- **Lint**: `npm run lint` (ESLint) +- **Test**: `npm run test` (Jest) + - Run a specific test file: `npm run test -- <path/to/file>` + - Run a specific test case: `npm run test -- -t 'test name'` -## Architecture +## High-Level Architecture -### Framework Stack - -- **Next.js 16** with App Router (not Pages Router) -- **Refine** framework for admin/CRUD operations -- **NextAuth.js** with Keycloak for SSO authentication -- **Material-UI (MUI) v6** for UI components -- **OpenLayers** + **Deck.gl** for map visualization - -### Route Structure - -- `src/app/layout.tsx` - Root layout with RefineContext -- `src/app/(main)/` - Protected routes with shared layout - - `/network-simulation` - Real-time network simulation - - `/scada-data-cleaning` - SCADA data management - - `/monitoring-place-optimization` - Sensor placement optimization - - `/health-risk-analysis` - Health risk assessment - - `/risk-analysis-location` - Risk location analysis - - `/network-partition-optimization` - Network partitioning -- `src/app/OlMap/` - Standalone map route with custom controls -- `src/app/login/` - Public authentication pages - -### Key Directories - -- `src/app/_refine_context.tsx` - Refine configuration with resources, auth provider, and data provider -- `src/providers/data-provider/` - REST API data provider (currently mock, update API_URL for production) -- `src/contexts/color-mode/` - Theme switching (light/dark mode persisted in cookies) -- `src/components/` - Reusable UI components (header, loading, olmap, title) -- `src/utils/` - Map utilities (layers.ts, mapQueryService.ts, color parsing) -- `src/config/config.ts` - Environment-based configuration with fallback defaults - -### Path Aliases (TypeScript) - -```typescript -@app/* -> src/app/* -@assets/* -> src/assets/* -@components/* -> src/components/* -@config/* -> src/config/* -@contexts/* -> src/contexts/* -@interfaces/* -> src/interfaces/* -@libs/* -> src/libs/* -@providers/* -> src/providers/* -@utils/* -> src/utils/* -@/* -> src/* -``` - -### Map Architecture - -The map system uses a hybrid approach: - -- **OpenLayers** as the base map engine (vector tiles from GeoServer) -- **Deck.gl** overlays for advanced visualizations (trips, contours, text labels) -- **DeckLayer** custom class bridges OL and Deck.gl (`@utils/layers`) -- Map data sourced from GeoServer MVT tiles (configured in `@config/config.ts`) -- Layers: junctions, pipes, valves, reservoirs, pumps, tanks, scada - -### Client vs Server Components - -- Most interactive components use `"use client"` directive (~35 files) -- Map components are always client-side (OpenLayers requires browser APIs) -- Layout and page files without interactivity can be server components +- **Framework**: **Next.js 16 (App Router)** integrated with **Refine** (`@refinedev/core`). +- **Routing**: + - Routes are defined in `src/app`. + - Refine resources (e.g., `/network-simulation`, `/hydraulic-simulation/*`) map directly to these routes. + - Configuration is central in `src/app/_refine_context.tsx`. +- **State Management**: + - **Global App State**: **Zustand** (`src/store`). + - **Server State**: Managed by Refine hooks (`useList`, `useOne`, etc.) via **React Query**. +- **Authentication**: + - **NextAuth.js** handling Keycloak integration. + - Session token is synced to Zustand (`useAuthStore`) in `RefineContext`. +- **Data Layer**: + - Custom Data Provider: `src/providers/data-provider`. + - API Utilities: `src/lib/api.ts`, `src/lib/apiFetch.ts`. +- **UI & Styling**: + - **Material UI (MUI)**: Primary component library (`@mui/material`, `@refinedev/mui`). + - **Tailwind CSS v4**: Utility classes for layout and custom styling (`@tailwindcss/postcss`). + - **Mapping**: OpenLayers (`ol`), deck.gl, Turf.js. + - **Charts**: ECharts, MUI X Charts. ## Key Conventions -### Authentication Flow - -- Keycloak SSO via NextAuth.js (`src/app/api/auth/[...nextauth]/`) -- Session managed with `SessionProvider` wrapper -- Auth check redirects to `/login` if unauthenticated -- Use `useSession()` hook for current user data - -### Environment Variables - -- All frontend-accessible variables must have `NEXT_PUBLIC_` prefix -- Backend URL: `NEXT_PUBLIC_BACKEND_URL` (defaults to http://192.168.1.42:8000) -- GeoServer URL: `NEXT_PUBLIC_MAP_URL` (defaults to http://127.0.0.1:8080/geoserver) -- Map layers: `NEXT_PUBLIC_MAP_AVAILABLE_LAYERS` (comma-separated) -- Keycloak config in `.env.local` (not committed) - -### Refine Resources - -Resources defined in `_refine_context.tsx` use Chinese labels and route to pages in `(main)/`: - -- Each resource has: name (Chinese), list (route path), meta (icon + label) -- Icons from `react-icons` library -- No CRUD operations defined (list-only pages) - -### Map Styling - -- Default styles in `config.MAP_DEFAULT_STYLE` (stroke, circle, colors) -- Circle radius uses zoom-based interpolation (1px at z12, 8px at z24) -- WebGL rendering for vector tiles -- Style legends generated dynamically in map controls - -### TypeScript Configuration - -- Strict mode enabled -- Path aliases match jest.config.js mappings -- Target ES5 for broader compatibility -- Incremental builds enabled - -### Next.js Configuration - -- **Standalone output** for Docker deployment -- SVG files handled by `@svgr/webpack` (imported as React components) -- No custom server or middleware - -### Testing Setup - -- Jest with React Testing Library -- jsdom environment for component testing -- Path aliases configured to match tsconfig.json -- Setup file at `jest.setup.js` - -## Common Patterns - -### Adding a New Route - -1. Create directory in `src/app/(main)/your-route/` -2. Add `page.tsx` and optional `loading.tsx` -3. Register resource in `src/app/_refine_context.tsx` resources array -4. Import icon from `react-icons` - -### Working with Maps - -- Use `MapComponent` from `src/app/OlMap/MapComponent.tsx` -- Access map context via `useMapData()` hook -- Vector tile layers auto-load from GeoServer workspace -- Custom overlays use Deck.gl layers (TextLayer, TripsLayer, ContourLayer) - -### API Calls - -- Update `dataProvider` in `src/providers/data-provider/index.ts` for real backend -- Currently points to `https://api.fake-rest.refine.dev` -- Use Refine hooks (`useList`, `useOne`, etc.) for data fetching - -### Theme Management - -- Theme stored in cookies (not localStorage) -- Toggle via `ColorModeContext` from `@contexts/color-mode` -- Supports light/dark modes only -- Default mode read from cookie in root layout (server-side) +- **Refine Integration**: + - Use Refine hooks (`useTable`, `useForm`, `useNavigation`) for data-heavy components. + - Resources are defined in the `<Refine>` component in `src/app/_refine_context.tsx`. +- **Project Structure**: + - `src/components/`: Grouped by feature (e.g., `olmap`, `project`) or common UI elements. + - `src/lib/`: Utility functions and API helpers. + - `src/providers/`: Refine providers (data, etc.). +- **Imports**: + - Use absolute imports with `@/` alias (e.g., `@/components`, `@/store`, `@/lib`). + - _Note_: `@libs` alias in tsconfig points to non-existent `src/libs` folder; prefer `@/lib`. +- **Styling**: + - Prefer MUI components for standard UI elements. + - Use Tailwind utility classes for layout and custom overrides. -- 2.54.0 From 76aa28c701ee832ce95a1618ab04dbeeb2be3ed9 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Thu, 12 Mar 2026 11:40:37 +0800 Subject: [PATCH 048/281] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=20key=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/BurstDetection/AnalysisParameters.tsx | 6 +++--- src/components/olmap/BurstLocation/AnalysisParameters.tsx | 6 +++--- .../olmap/DMALeakDetection/AnalysisParameters.tsx | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index 96725b4..d7c763c 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -170,7 +170,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setRunning(true); open?.({ - key: "burst-detection-analysis", + key: "burst-detection-analysis-progress", type: "progress", message: "正在执行爆管侦测", description: "正在读取数据并计算异常分数。", @@ -213,14 +213,14 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { }); open?.({ - key: "burst-detection-analysis", + key: "burst-detection-analysis-success", type: "success", message: "爆管侦测完成", description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, }); } catch (error: any) { open?.({ - key: "burst-detection-analysis", + key: "burst-detection-analysis-error", type: "error", message: "侦测失败", description: error?.response?.data?.detail ?? error?.message ?? "请求失败", diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index b082362..d721187 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -162,7 +162,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setRunning(true); open?.({ - key: "burst-location-analysis", + key: "burst-location-analysis-progress", type: "progress", message: "方案提交分析中", undoableTimeout: 3, @@ -193,14 +193,14 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { onResult(response.data as BurstLocationResult); open?.({ - key: "burst-location-analysis", + key: "burst-location-analysis-success", type: "success", message: "爆管定位成功", description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`, }); } catch (error: any) { open?.({ - key: "burst-location-analysis", + key: "burst-location-analysis-error", type: "error", message: "提交分析失败", description: error?.response?.data?.detail ?? error?.message ?? "请求失败", diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 4a796e3..5815867 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -52,7 +52,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { } setRunning(true); open?.({ - key: "dma-leak-analysis", + key: "dma-leak-analysis-progress", type: "progress", message: "方案提交分析中", undoableTimeout: 3, @@ -75,14 +75,14 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { ); onResult(response.data as LeakageResultDetail); open?.({ - key: "dma-leak-analysis", + key: "dma-leak-analysis-success", type: "success", message: "方案分析成功", description: "DMA 漏损识别完成,请在方案查询中查看结果。", }); } catch (error: any) { open?.({ - key: "dma-leak-analysis", + key: "dma-leak-analysis-error", type: "error", message: "提交分析失败", description: error?.response?.data?.detail ?? "请求失败", -- 2.54.0 From a7106a7289d695b3007d223f84d7ed0dbbce8540 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Thu, 12 Mar 2026 18:43:42 +0800 Subject: [PATCH 049/281] =?UTF-8?q?=E9=9A=90=E8=97=8F=E4=BE=A6=E6=B5=8B?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E9=83=A8=E5=88=86=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/BurstDetection/DetectionResults.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx index 6b9d914..d216eee 100644 --- a/src/components/olmap/BurstDetection/DetectionResults.tsx +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -473,7 +473,7 @@ const DetectionResults: React.FC<Props> = ({ result }) => { </Box> {/* Selected Day Interpretation */} - <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + {/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Typography variant="subtitle1" className="font-bold text-gray-800"> 选中日解读 @@ -522,10 +522,10 @@ const DetectionResults: React.FC<Props> = ({ result }) => { 请在趋势图或表格中选择一天查看详细解释。 </Typography> )} - </Box> + </Box> */} {/* Latest Sensor Rankings */} - <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + {/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> <Typography variant="subtitle1" className="font-bold text-gray-800"> 最新测点高频特征排名 @@ -554,7 +554,7 @@ const DetectionResults: React.FC<Props> = ({ result }) => { </Button> ))} </Box> - </Box> + </Box> */} {/* Results Table */} <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> -- 2.54.0 From 081e4c4c13263fb11a177e7f7373125a31ec7dc6 Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 13 Mar 2026 17:36:42 +0800 Subject: [PATCH 050/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=92=8C=E6=89=93=E5=8C=85=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package.yml | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/package.yml diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..66bbf05 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,43 @@ +name: Build and Lint + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + strategy: + matrix: + node-version: [24.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Package Source Code + run: | + tar -czf source-code.tar.gz \ + --exclude='.git*' \ + --exclude='.github' \ + --exclude='node_modules' \ + --exclude='.next' \ + --exclude='dist' \ + --exclude='source-code.tar.gz' \ + . + + - name: Upload Source Artifact + uses: actions/upload-artifact@v4 + with: + name: source-code + path: source-code.tar.gz -- 2.54.0 From 71be47b956529bfd37dad66d7862de99b08d7e7c Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 13 Mar 2026 17:37:43 +0800 Subject: [PATCH 051/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=A0=BC=E5=BC=8F=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=BC=95=E5=8F=B7=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package.yml | 51 ++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 66bbf05..be007ab 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -3,41 +3,42 @@ name: Build and Lint on: push: tags: - - 'v*' + - "v*" jobs: build: - runs-on: ubuntu-latest env: + runs-on: ubuntu-latest + env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true strategy: matrix: node-version: [24.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" - - name: Install dependencies - run: npm ci + - name: Install dependencies + run: npm ci - - name: Package Source Code - run: | - tar -czf source-code.tar.gz \ - --exclude='.git*' \ - --exclude='.github' \ - --exclude='node_modules' \ - --exclude='.next' \ - --exclude='dist' \ - --exclude='source-code.tar.gz' \ - . + - name: Package Source Code + run: | + tar -czf source-code.tar.gz \ + --exclude='.git*' \ + --exclude='.github' \ + --exclude='node_modules' \ + --exclude='.next' \ + --exclude='dist' \ + --exclude='source-code.tar.gz' \ + . - - name: Upload Source Artifact - uses: actions/upload-artifact@v4 - with: - name: source-code - path: source-code.tar.gz + - name: Upload Source Artifact + uses: actions/upload-artifact@v4 + with: + name: source-code + path: source-code.tar.gz -- 2.54.0 From abfc8770a42e14cdb922465b8310f8536d9274cf Mon Sep 17 00:00:00 2001 From: JIANG <jiang@email.com> Date: Fri, 13 Mar 2026 17:39:41 +0800 Subject: [PATCH 052/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=BA=90=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=89=93=E5=8C=85=E6=AD=A5=E9=AA=A4=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=8E=92=E9=99=A4=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index be007ab..649631c 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -28,9 +28,7 @@ jobs: - name: Package Source Code run: | - tar -czf source-code.tar.gz \ - --exclude='.git*' \ - --exclude='.github' \ + tar --warning=no-file-changed -czf source-code.tar.gz \ --exclude='node_modules' \ --exclude='.next' \ --exclude='dist' \ -- 2.54.0 From e0ab4bf60d3748c1127e0bdec2cee431b988ee7e Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 13 Mar 2026 17:52:45 +0800 Subject: [PATCH 053/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=89=93=E5=8C=85?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E7=BC=A9=E8=BF=9B=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 649631c..e110f47 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -28,7 +28,7 @@ jobs: - name: Package Source Code run: | - tar --warning=no-file-changed -czf source-code.tar.gz \ + tar --warning=no-file-changed -czf source-code.tar.gz \ --exclude='node_modules' \ --exclude='.next' \ --exclude='dist' \ -- 2.54.0 From e1e4664dece0673cd8d53a2cbd03326aab383f9b Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 17 Mar 2026 10:41:46 +0800 Subject: [PATCH 054/281] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=88=86=E5=8C=BA=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loading.tsx | 5 - .../network-partition-optimization/page.tsx | 14 - src/app/_refine_context.tsx | 9 - .../ZonePropsPanel.tsx | 418 ------------------ 4 files changed, 446 deletions(-) delete mode 100644 src/app/(main)/network-partition-optimization/loading.tsx delete mode 100644 src/app/(main)/network-partition-optimization/page.tsx delete mode 100644 src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx diff --git a/src/app/(main)/network-partition-optimization/loading.tsx b/src/app/(main)/network-partition-optimization/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/network-partition-optimization/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/network-partition-optimization/page.tsx b/src/app/(main)/network-partition-optimization/page.tsx deleted file mode 100644 index 07aa7aa..0000000 --- a/src/app/(main)/network-partition-optimization/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import ZonePropsPanel from "@components/olmap/NetworkPartitionOptimization/ZonePropsPanel"; -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <ZonePropsPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index b2f81f5..b7f1441 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -21,7 +21,6 @@ import { LiaNetworkWiredSolid } from "react-icons/lia"; import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; -import { AiOutlinePartition } from "react-icons/ai"; import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { MyLocation as MyLocationIcon, @@ -228,14 +227,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => { label: "管道冲洗", }, }, - { - name: "管网优化分区", - list: "/network-partition-optimization", - meta: { - icon: <AiOutlinePartition className="w-6 h-6" />, - label: "管网优化分区", - }, - }, ]} options={{ syncWithLocation: true, diff --git a/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx b/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx deleted file mode 100644 index 32d43ab..0000000 --- a/src/components/olmap/NetworkPartitionOptimization/ZonePropsPanel.tsx +++ /dev/null @@ -1,418 +0,0 @@ -import React, { useEffect, useCallback, useState, useRef } from "react"; -import VectorLayer from "ol/layer/Vector"; -import VectorSource from "ol/source/Vector"; -import Style from "ol/style/Style"; -import Fill from "ol/style/Fill"; -import { Stroke } from "ol/style"; -import GeoJson from "ol/format/GeoJSON"; -import config from "@config/config"; -import { useMap } from "@components/olmap/core/MapComponent"; -import { useProject } from "@/contexts/ProjectContext"; - -interface PropertyItem { - key: string; - value: string | number | boolean; - label?: string; -} - -interface ZonePropsPanelProps { - title?: string; - isVisible?: boolean; - onClose?: () => void; -} - -const ZonePropsPanel: React.FC<ZonePropsPanelProps> = ({ - title = "分区属性信息", - isVisible = true, - onClose, -}) => { - const map = useMap(); - const project = useProject(); - const workspace = project?.workspace; - - const [props, setProps] = React.useState< - PropertyItem[] | Record<string, any> - >({}); - const [highlightedFeature, setHighlightedFeature] = useState<any>(null); - const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); - - const handleMapClickSelectFeatures = useCallback( - (pixel: number[]) => { - if (!map || !highlightLayerRef.current) return; - let clickedFeature: any = null; - map.forEachFeatureAtPixel(pixel, (feature) => { - if (!clickedFeature) { - clickedFeature = feature; - } - }); - if (clickedFeature) { - const layer = clickedFeature?.getId()?.toString().split(".")[0]; - if (layer !== "network_zone") { - return; - } - setHighlightedFeature(clickedFeature); - setProps(clickedFeature.getProperties()); - // 更新高亮图层 - const source = highlightLayerRef.current.getSource(); - source?.clear(); - source?.addFeature(clickedFeature); - } else { - setHighlightedFeature(null); - setProps({}); - // 清空高亮图层 - const source = highlightLayerRef.current.getSource(); - source?.clear(); - } - }, - [map] - ); - - // 将 properties 转换为统一格式 - const formatProperties = ( - props: PropertyItem[] | Record<string, any> - ): PropertyItem[] => { - if (Array.isArray(props)) { - return props.filter((item) => !shouldHideProperty(item.key)); - } - - return Object.entries(props) - .filter(([key]) => !shouldHideProperty(key)) - .map(([key, value]) => ({ - key, - value, - label: getChineseLabel(key), - })); - }; - - // 判断是否应该隐藏某个属性 - const shouldHideProperty = (key: string): boolean => { - const hiddenKeys = [ - "id", - "geometry", - "Note1", - "Note3", - "Note4", - "Note5", - "Note6", - "Note7", - "Note8", - "Note9", - "Note10", - ]; - return hiddenKeys.includes(key); - }; - - useEffect(() => { - if (!map) { - return; - } - const workspaceValue = workspace || config.MAP_WORKSPACE; - const networkZoneLayer = new VectorLayer({ - source: new VectorSource({ - url: `${config.MAP_URL}/${workspaceValue}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspaceValue}:network_zone&outputFormat=application/json`, - format: new GeoJson(), - }), - style: new Style({ - fill: new Fill({ - color: "rgba(255, 255, 255, 0)", - }), - stroke: new Stroke({ - color: "#e01414ff", - width: 5, - }), - }), - properties: { - name: "管网分区", - value: "network_zone", - }, - }); - map.addLayer(networkZoneLayer); - - // 创建高亮图层 - const highlightLayer = new VectorLayer({ - source: new VectorSource(), - style: new Style({ - fill: new Fill({ - color: "rgba(255, 255, 0, 0.3)", - }), - stroke: new Stroke({ - color: "#ff0000", - width: 3, - }), - }), - properties: { - name: "高亮分区", - value: "highlight_zone", - }, - }); - map.addLayer(highlightLayer); - highlightLayerRef.current = highlightLayer; - - const clickListener = (evt: any) => { - handleMapClickSelectFeatures(evt.pixel); - }; - - map.on("click", clickListener); - - return () => { - map.removeLayer(networkZoneLayer); - map.removeLayer(highlightLayer); - map.un("click", clickListener); - }; - }, [map, handleMapClickSelectFeatures, workspace]); - // 获取中文标签 - const getChineseLabel = (key: string): string => { - const labelMap: Record<string, string> = { - Id: "ID", - Area: "面积", - Complete: "完成度", - Consumptio: "消耗", - Descriptio: "描述", - FlowError: "流量误差", - Level: "级别", - ModelFlow: "模型流量", - NRW: "无收益水量", - NRWPercent: "无收益水量百分比", - Name: "名称", - Note1: "备注1", - Note2: "备注2", - Note3: "备注3", - Note4: "备注4", - Note5: "备注5", - Note6: "备注6", - Note7: "备注7", - Note8: "备注8", - Note9: "备注9", - Note10: "备注10", - ParentZone: "父区域", - PipeLength: "管道长度", - Population: "人口", - ScadaFlow: "SCADA流量", - Tag: "标签", - TotalFlowE: "总流量误差", - TotalModel: "总模型", - TotalScada: "总SCADA", - WaterConsu: "水消耗", - WaterSuppl: "水供应", - }; - return labelMap[key] || key; - }; - - // 优先使用从store中获取的props,如果没有则使用传入的properties - const dataToShow = props; - const formattedProperties = formatProperties(dataToShow); - - // 定义属性的显示顺序 - const propertyOrder = [ - "Id", - "Name", - "PipeLength", - "ModelFlow", - "Population", - "Level", - "Note2", - "Area", - "Descriptio", - "ParentZone", - "Tag", - "Complete", - "Consumptio", - "FlowError", - "NRW", - "NRWPercent", - "ScadaFlow", - "TotalFlowE", - "TotalModel", - "TotalScada", - "WaterConsu", - "WaterSuppl", - ]; - - // 根据自定义顺序对属性进行排序 - const sortedProperties = [...formattedProperties].sort((a, b) => { - const aIndex = propertyOrder.indexOf(a.key); - const bIndex = propertyOrder.indexOf(b.key); - - // 如果属性不在排序列表中,则将其放在末尾 - if (aIndex === -1) return 1; - if (bIndex === -1) return -1; - - return aIndex - bIndex; - }); - - // 格式化值显示 - const formatValue = (value: any, key: string): string => { - if (value === null || value === undefined) { - return "-"; - } - if (typeof value === "boolean") { - return value ? "是" : "否"; - } - if (typeof value === "string" && value.trim() === "") { - return "-"; - } - - // 对于特定的数值字段,添加单位 - if (typeof value === "number") { - switch (key) { - case "Area": - return `${value.toLocaleString()} m²`; - case "PipeLength": - return `${value.toLocaleString()} m`; - case "Population": - return `${value.toLocaleString()} 人`; - case "ModelFlow": - return `${value.toLocaleString()} L/天`; - case "ScadaFlow": - case "TotalModel": - case "TotalScada": - case "WaterConsu": - case "WaterSuppl": - return `${value.toLocaleString()} L/s`; - case "NRWPercent": - return value !== null ? `${value}%` : "-"; - default: - return value.toLocaleString(); - } - } - - return String(value); - }; - - if (!isVisible) { - return null; - } - - const isImportantKeys = ["Name", "Id", "ModelFlow", "Area", "PipeLength"]; - - return ( - <div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm opacity-95 hover:opacity-100 transition-all duration-300"> - {/* 头部 */} - <div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white"> - <div className="flex items-center gap-2"> - <svg - className="w-5 h-5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - /> - </svg> - <h3 className="text-lg font-semibold">{title}</h3> - </div> - {onClose && ( - <button - onClick={onClose} - className="text-white hover:bg-white hover:bg-opacity-20 rounded-full p-1 transition-all duration-200" - aria-label="关闭" - > - <svg - className="w-5 h-5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M6 18L18 6M6 6l12 12" - /> - </svg> - </button> - )} - </div> - - {/* 内容区域 */} - <div className="flex-1 overflow-y-auto px-4 py-3"> - {sortedProperties.length === 0 ? ( - <div className="flex flex-col items-center justify-center py-12 text-gray-400"> - <svg - className="w-16 h-16 mb-3" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={1.5} - d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" - /> - </svg> - <p className="text-sm">暂无属性信息</p> - <p className="text-xs mt-1">点击地图分区查看详情</p> - </div> - ) : ( - <div className="space-y-2"> - {sortedProperties.map((item, index) => { - const isImportant = isImportantKeys.includes(item.key); - return ( - <div - key={item.key || index} - className={`group rounded-lg p-3 transition-all duration-200 ${ - isImportant - ? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500" - : "bg-gray-50 hover:bg-gray-100" - }`} - > - <div className="flex justify-between items-start gap-3"> - <span - className={`font-medium text-xs uppercase tracking-wide ${ - isImportant ? "text-blue-700" : "text-gray-600" - }`} - > - {item.label || item.key} - </span> - <span - className={`text-sm font-semibold text-right flex-1 ${ - isImportant ? "text-blue-900" : "text-gray-800" - }`} - > - {formatValue(item.value, item.key)} - </span> - </div> - </div> - ); - })} - </div> - )} - </div> - - {/* 底部统计区域 */} - <div className="px-5 py-3 bg-gray-50 border-t border-gray-200"> - <div className="flex items-center justify-between text-xs"> - <span className="text-gray-600 flex items-center gap-1"> - <svg - className="w-4 h-4" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" - /> - </svg> - 共 {sortedProperties.length} 个属性 - </span> - {highlightedFeature && ( - <span className="text-green-600 flex items-center gap-1 font-medium"> - <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span> - 已选中 - </span> - )} - </div> - </div> - </div> - ); -}; - -export default ZonePropsPanel; -- 2.54.0 From d232104aa4899297a25325a3484d355c91002b1f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 17 Mar 2026 18:42:11 +0800 Subject: [PATCH 055/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E5=92=8C=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contexts/ProjectContext.tsx | 53 +++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 98d69cc..5f575ce 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -51,27 +51,54 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ setIsConfigured(true); try { - const response = await apiFetch( + // Open project backend (simulation model) + const openResponse = await apiFetch( `${config.BACKEND_URL}/openproject/?network=${net}`, { method: "POST", }, ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + if (!openResponse.ok) { + throw new Error(`Failed to open project: HTTP ${openResponse.status}`); } - const data = await response.json(); - const bbox = Array.isArray(data?.map_extent?.bbox) - ? data.map_extent.bbox.map((value: number) => Number(value)) - : null; - if (bbox && bbox.length === 4) { - setMapExtent(bbox); - localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", bbox.join(",")); - localStorage.removeItem(`${ws}_map_view`); - setCurrentProject((prev) => ({ ...prev, extent: bbox })); + + // Fetch project metadata + const infoResponse = await apiFetch( + `${config.BACKEND_URL}/project_info/?network=${net}`, + ); + if (!infoResponse.ok) { + console.warn( + `Failed to fetch project info: HTTP ${infoResponse.status}`, + ); + } else { + const data = await infoResponse.json(); + + // Update workspace if different + if (data?.gs_workspace && data.gs_workspace !== ws) { + setMapWorkspace(data.gs_workspace); + localStorage.setItem( + "NEXT_PUBLIC_MAP_WORKSPACE", + data.gs_workspace, + ); + setCurrentProject((prev) => ({ + ...prev, + workspace: data.gs_workspace, + })); + } + + // Update extent if available + const bbox = Array.isArray(data?.map_extent?.bbox) + ? data.map_extent.bbox.map((value: number) => Number(value)) + : null; + if (bbox && bbox.length === 4) { + setMapExtent(bbox); + localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", bbox.join(",")); + localStorage.removeItem(`${ws}_map_view`); + setCurrentProject((prev) => ({ ...prev, extent: bbox })); + } } } catch (error) { - console.error("Failed to open project:", error); + console.error("Failed to setup project:", error); } }, [setCurrentProjectId]); -- 2.54.0 From 55362bef8fd2b47097fe45ebb8c864b8140e68a1 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 19 Mar 2026 15:38:45 +0800 Subject: [PATCH 056/281] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=85=A8=E5=B1=80=20?= =?UTF-8?q?id=3D"deck-canvas"=20=E8=B7=AF=E5=BE=84=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=AE=9E=E4=BE=8B=E7=BA=A7=20canvasRef=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=AF=E8=83=BD=E5=87=BA=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=20Uncaught=20Error:=20deck.gl:=20assertion=20failed=20?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/core/MapComponent.tsx | 74 ++++++++++++++++++---- src/utils/layers.ts | 50 ++++++++++++++- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 0374982..2fa0abb 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -127,7 +127,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key const mapRef = useRef<HTMLDivElement | null>(null); + const canvasRef = useRef<HTMLCanvasElement | null>(null); const deckLayerRef = useRef<DeckLayer | null>(null); + const isDisposingRef = useRef(false); + const pendingTimeoutsRef = useRef<number[]>([]); const [map, setMap] = useState<OlMap>(); const [deckLayer, setDeckLayer] = useState<DeckLayer>(); @@ -518,14 +521,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // The map and layer instances are intentionally rebuilt only when workspace or extent changes. useEffect(() => { if (!mapRef.current) return; + if (!canvasRef.current) { + return; + } + isDisposingRef.current = false; + + const addTimeout = (callback: () => void, delay: number) => { + const timerId = window.setTimeout(() => { + pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter( + (id) => id !== timerId, + ); + if (isDisposingRef.current) return; + callback(); + }, delay); + pendingTimeoutsRef.current.push(timerId); + return timerId; + }; + + const clearPendingTimeouts = () => { + pendingTimeoutsRef.current.forEach((id) => clearTimeout(id)); + pendingTimeoutsRef.current = []; + }; + // 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示 - junctionSource.on("tileloadend", (event) => { + const handleJunctionTileLoadEnd = (event: any) => { + if (isDisposingRef.current) return; try { if (event.tile instanceof VectorTile) { const renderFeatures = event.tile.getFeatures(); const data = new Map(); - renderFeatures.forEach((renderFeature) => { + renderFeatures.forEach((renderFeature: any) => { const props = renderFeature.getProperties(); const featureId = props.id; if (featureId && !junctionDataIds.current.has(featureId)) { @@ -554,14 +580,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { } catch (error) { console.error("Junction tile load error:", error); } - }); - pipeSource.on("tileloadend", (event) => { + }; + const handlePipeTileLoadEnd = (event: any) => { + if (isDisposingRef.current) return; try { if (event.tile instanceof VectorTile) { const renderFeatures = event.tile.getFeatures(); const data = new Map(); - renderFeatures.forEach((renderFeature) => { + renderFeatures.forEach((renderFeature: any) => { try { const props = renderFeature.getProperties(); const featureId = props.id; @@ -634,7 +661,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { } catch (error) { console.error("Pipe tile load error:", error); } - }); + }; + junctionSource.on("tileloadend", handleJunctionTileLoadEnd); + pipeSource.on("tileloadend", handlePipeTileLoadEnd); // 监听 junctionsLayer 的 visible 变化 const handleJunctionVisibilityChange = () => { const isVisible = junctionsLayer.getVisible(); @@ -748,6 +777,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { } // 持久化视图(中心点 + 缩放),防抖写入 localStorage const persistView = debounce(() => { + if (isDisposingRef.current) return; try { const view = map.getView(); const center = view.getCenter(); @@ -765,7 +795,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 监听缩放变化并持久化,同时更新 currentZoom const handleViewChange = () => { - setTimeout(() => { + addTimeout(() => { const zoom = map.getView().getZoom() || 0; setCurrentZoom(zoom); persistView(); @@ -774,7 +804,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { map.getView().on("change", handleViewChange); // 初始化当前缩放级别并强制触发瓦片加载 - setTimeout(() => { + addTimeout(() => { const initialZoom = map.getView().getZoom() || 11; setCurrentZoom(initialZoom); // 强制触发地图渲染,让瓦片加载事件触发 @@ -788,11 +818,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { latitude: 0, zoom: 1, }, - canvas: "deck-canvas", + canvas: canvasRef.current, controller: false, // 由 OpenLayers 控制视图 layers: [], }); - const deckLayer = new DeckLayer(deck, { + const deckLayer = new DeckLayer(deck, canvasRef.current, { name: "deckLayer", value: "deckLayer", }); @@ -802,19 +832,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 清理函数 return () => { + isDisposingRef.current = true; + clearPendingTimeouts(); + debouncedUpdateDataRef.current?.cancel(); + persistView.cancel(); + junctionSource.un("tileloadend", handleJunctionTileLoadEnd); + pipeSource.un("tileloadend", handlePipeTileLoadEnd); + map.getView().un("change", handleViewChange); junctionsLayer.un("change:visible", handleJunctionVisibilityChange); pipesLayer.un("change:visible", handlePipeVisibilityChange); + if (deckLayerRef.current && !deckLayerRef.current.isDisposedLayer()) { + try { + map.removeLayer(deckLayerRef.current); + } catch { + // Layer may have already been removed during teardown. + } + deckLayerRef.current.disposeDeck(); + } + deckLayerRef.current = null; + setDeckLayer(undefined); map.setTarget(undefined); map.dispose(); - deck.finalize(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [MAP_WORKSPACE, MAP_EXTENT]); // 当数据变化时,更新 deck.gl 图层 useEffect(() => { + if (isDisposingRef.current) return; const deckLayer = deckLayerRef.current; if (!deckLayer) return; // 如果 deck 实例还未创建,则退出 + if (deckLayer.isDisposedLayer()) return; if (!mergedJunctionData.length) return; if (!mergedPipeData.length) return; const junctionTextLayer = new TextLayer({ @@ -964,6 +1012,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 控制流动动画开关 useEffect(() => { + if (isDisposingRef.current) return; if (pipeText === "flow" && currentPipeCalData.length > 0) { flowAnimation.current = true; } else { @@ -976,6 +1025,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 动画循环 const animate = () => { + if (isDisposingRef.current || deckLayer.isDisposedLayer()) return; // 动画总时长(秒) const animationDuration = 10; const bufferTime = 2; @@ -1075,7 +1125,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { <MapTools /> {children} </div> - <canvas id="deck-canvas" /> + <canvas ref={canvasRef} /> </MapContext.Provider> </DataContext.Provider> </> diff --git a/src/utils/layers.ts b/src/utils/layers.ts index 3febb12..c9ecc30 100644 --- a/src/utils/layers.ts +++ b/src/utils/layers.ts @@ -8,6 +8,8 @@ import { toLonLat } from "ol/proj"; */ export class DeckLayer extends Layer { private deck: Deck; + private canvasEl: HTMLCanvasElement | null; + private isDisposed = false; private onVisibilityChange?: (layerId: string, visible: boolean) => void; private userVisibility: Map<string, boolean> = new Map(); // 存储用户设置的可见性 @@ -15,10 +17,15 @@ export class DeckLayer extends Layer { * @param deckInstance deck.gl 实例 * @param layerProperties 可选:在构造时直接设置到 OpenLayers Layer 的 properties */ - constructor(deckInstance: Deck, layerProperties?: Record<string, any>) { + constructor( + deckInstance: Deck, + canvasElement: HTMLCanvasElement | null, + layerProperties?: Record<string, any> + ) { // 将 layerProperties 作为 Layer 的 properties 传入 super({ properties: layerProperties || {} }); this.deck = deckInstance; + this.canvasEl = canvasElement; // 再次确保属性应用到实例(兼容场景) if (layerProperties) { this.setProperties(layerProperties); @@ -38,6 +45,9 @@ export class DeckLayer extends Layer { } render(frameState: any): HTMLElement { + if (this.isDisposed) { + return this.canvasEl || document.createElement("div"); + } const { size, viewState } = frameState; const [width, height] = size; const [longitude, latitude] = toLonLat(viewState.center); @@ -46,7 +56,7 @@ export class DeckLayer extends Layer { const deckViewState = { bearing, longitude, latitude, zoom }; this.deck.setProps({ width, height, viewState: deckViewState }); this.deck.redraw(); - return document.getElementById("deck-canvas") as HTMLElement; + return this.canvasEl || document.createElement("div"); } // 获取 Deck 实例 @@ -56,16 +66,19 @@ export class DeckLayer extends Layer { // 设置图层 setDeckLayers(layers: any[]): void { + if (this.isDisposed) return; this.deck.setProps({ layers }); } // 获取当前图层 getDeckLayers(): any[] { + if (this.isDisposed) return []; return this.deck.props.layers || []; } // 添加图层 addDeckLayer(layer: any): void { + if (this.isDisposed) return; const currentLayers = this.getDeckLayers(); // 如果已有同 id 图层,则替换保持顺序;否则追加 const idx = currentLayers.findIndex((l: any) => l && l.id === layer.id); @@ -80,6 +93,7 @@ export class DeckLayer extends Layer { // 移除图层 removeDeckLayer(layerId: string): void { + if (this.isDisposed) return; const currentLayers = this.getDeckLayers(); const filteredLayers = currentLayers.filter( (layer: any) => layer && layer.id !== layerId @@ -97,6 +111,7 @@ export class DeckLayer extends Layer { // - 如果传入的是 Layer 实例,则直接替换同 id 的图层为该实例 // - 如果传入的是 props(普通对象),则基于原图层调用 clone(props) updateDeckLayer(layerId: string, layerOrProps: any): void { + if (this.isDisposed) return; const layers = this.getDeckLayers(); const updatedLayers = layers.map((layer: any) => { if (!layer || layer.id !== layerId) return layer; @@ -111,6 +126,13 @@ export class DeckLayer extends Layer { // 替换为新的 layer 实例 return layerOrProps; } + + if (layerOrProps && typeof layer.clone === "function") { + // 传入 props 时,基于原图层 clone,避免丢失类型 + return layer.clone(layerOrProps); + } + + return layer; }); this.deck.setProps({ layers: updatedLayers }); @@ -137,7 +159,7 @@ export class DeckLayer extends Layer { if (!found) return; try { // 使用 clone 来确保保持同类型实例 - this.updateDeckLayer(layerId, { ...found.props, visible }); + this.updateDeckLayer(layerId, { visible }); } catch (err) { // 降级:直接替换属性 this.updateDeckLayer(layerId, { visible }); @@ -160,4 +182,26 @@ export class DeckLayer extends Layer { setLayerProperties(props: Record<string, any>): void { this.setProperties(props); } + + isDisposedLayer(): boolean { + return this.isDisposed; + } + + disposeDeck(): void { + if (this.isDisposed) return; + this.isDisposed = true; + this.onVisibilityChange = undefined; + this.userVisibility.clear(); + try { + this.deck.setProps({ layers: [] }); + } catch (error) { + console.warn("Clear deck layers failed", error); + } + try { + this.deck.finalize(); + } catch (error) { + console.warn("Finalize deck failed", error); + } + this.canvasEl = null; + } } -- 2.54.0 From accf6ad254fe537000ecaf183e257e7f9cf0659a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 23 Mar 2026 18:03:24 +0800 Subject: [PATCH 057/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=A8=E5=B1=80=20?= =?UTF-8?q?Copilot=20=E8=81=8A=E5=A4=A9=E6=A1=86=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 184 ++++++++++++++++++++++++++ src/components/header/index.tsx | 14 ++ src/lib/chatStream.test.ts | 81 ++++++++++++ src/lib/chatStream.ts | 115 ++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 src/components/chat/GlobalChatbox.tsx create mode 100644 src/lib/chatStream.test.ts create mode 100644 src/lib/chatStream.ts diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx new file mode 100644 index 0000000..7153a20 --- /dev/null +++ b/src/components/chat/GlobalChatbox.tsx @@ -0,0 +1,184 @@ +"use client"; + +import ChatOutlined from "@mui/icons-material/ChatOutlined"; +import Close from "@mui/icons-material/Close"; +import Send from "@mui/icons-material/Send"; +import { + Box, + CircularProgress, + Drawer, + IconButton, + List, + ListItem, + ListItemText, + Stack, + TextField, + Typography, +} from "@mui/material"; +import React, { useMemo, useRef, useState } from "react"; +import { streamCopilotChat } from "@/lib/chatStream"; + +type Message = { + id: string; + role: "user" | "assistant"; + content: string; +}; + +type Props = { + open: boolean; + onClose: () => void; +}; + +const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { + const [messages, setMessages] = useState<Message[]>([]); + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [conversationId, setConversationId] = useState<string | undefined>(undefined); + const abortRef = useRef<AbortController | null>(null); + + const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); + + const handleSend = async () => { + const prompt = input.trim(); + if (!prompt || isStreaming) return; + + const userId = createId(); + const assistantId = createId(); + setInput(""); + setIsStreaming(true); + + setMessages((prev) => [ + ...prev, + { id: userId, role: "user", content: prompt }, + { id: assistantId, role: "assistant", content: "" }, + ]); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + await streamCopilotChat({ + message: prompt, + conversationId, + signal: controller.signal, + onEvent: (event) => { + if (event.type === "token") { + if (!conversationId && event.conversationId) { + setConversationId(event.conversationId); + } + setMessages((prev) => + prev.map((item) => + item.id === assistantId + ? { ...item, content: `${item.content}${event.content}` } + : item, + ), + ); + } else if (event.type === "done") { + if (!conversationId && event.conversationId) { + setConversationId(event.conversationId); + } + setIsStreaming(false); + } else if (event.type === "error") { + setMessages((prev) => + prev.map((item) => + item.id === assistantId + ? { + ...item, + content: + item.content || + `Error: ${event.message}${event.detail ? ` (${event.detail})` : ""}`, + } + : item, + ), + ); + setIsStreaming(false); + } + }, + }); + } catch (error) { + setMessages((prev) => + prev.map((item) => + item.id === assistantId + ? { ...item, content: `Error: ${String(error)}` } + : item, + ), + ); + setIsStreaming(false); + } finally { + abortRef.current = null; + setIsStreaming(false); + } + }; + + const handleAbort = () => { + abortRef.current?.abort(); + setIsStreaming(false); + }; + + return ( + <Drawer anchor="right" open={open} onClose={onClose}> + <Box sx={{ width: { xs: "100vw", sm: 420 }, height: "100%", display: "flex", flexDirection: "column" }}> + <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ p: 2, borderBottom: "1px solid", borderColor: "divider" }}> + <Stack direction="row" alignItems="center" spacing={1}> + <ChatOutlined /> + <Typography variant="subtitle1" fontWeight={600}> + Copilot Chat + </Typography> + </Stack> + <IconButton onClick={onClose} size="small"> + <Close /> + </IconButton> + </Stack> + + <List sx={{ flex: 1, overflow: "auto", px: 1.5 }}> + {messages.map((message) => ( + <ListItem key={message.id} sx={{ justifyContent: message.role === "user" ? "flex-end" : "flex-start" }}> + <Box + sx={{ + maxWidth: "86%", + px: 1.5, + py: 1, + borderRadius: 2, + bgcolor: message.role === "user" ? "primary.main" : "grey.100", + color: message.role === "user" ? "primary.contrastText" : "text.primary", + whiteSpace: "pre-wrap", + }} + > + <ListItemText primaryTypographyProps={{ variant: "body2" }} primary={message.content || "..."} /> + </Box> + </ListItem> + ))} + </List> + + <Stack direction="row" spacing={1} sx={{ p: 1.5, borderTop: "1px solid", borderColor: "divider" }}> + <TextField + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }} + size="small" + multiline + maxRows={4} + fullWidth + placeholder="输入消息..." + /> + {isStreaming ? ( + <IconButton color="warning" onClick={handleAbort}> + <CircularProgress size={20} /> + </IconButton> + ) : ( + <IconButton color="primary" disabled={!canSend} onClick={() => void handleSend()}> + <Send /> + </IconButton> + )} + </Stack> + </Box> + </Drawer> + ); +}; diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index dfb23f9..14d14d5 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -5,6 +5,7 @@ import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined"; import LightModeOutlined from "@mui/icons-material/LightModeOutlined"; import Logout from "@mui/icons-material/Logout"; import SwapHoriz from "@mui/icons-material/SwapHoriz"; +import ChatOutlined from "@mui/icons-material/ChatOutlined"; import AppBar from "@mui/material/AppBar"; import Avatar from "@mui/material/Avatar"; import ButtonBase from "@mui/material/ButtonBase"; @@ -21,6 +22,7 @@ import { useGetIdentity, useLogout } from "@refinedev/core"; import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui"; import React, { useContext, useState } from "react"; import { ProjectSelector } from "@components/project/ProjectSelector"; +import { GlobalChatbox } from "@components/chat/GlobalChatbox"; import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; import { useProjectStore } from "@/store/projectStore"; @@ -37,6 +39,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ const { mutate: logout } = useLogout(); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [showProjectSelector, setShowProjectSelector] = useState(false); + const [showChatbox, setShowChatbox] = useState(false); const open = Boolean(anchorEl); const setCurrentProjectId = useProjectStore( (state) => state.setCurrentProjectId, @@ -91,6 +94,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ justifyContent="flex-end" alignItems="center" > + <IconButton + color="inherit" + onClick={() => setShowChatbox(true)} + > + <ChatOutlined /> + </IconButton> + <IconButton color="inherit" onClick={() => { @@ -214,6 +224,10 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ /> </> )} + <GlobalChatbox + open={showChatbox} + onClose={() => setShowChatbox(false)} + /> </Stack> </Stack> </Toolbar> diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts new file mode 100644 index 0000000..9956de2 --- /dev/null +++ b/src/lib/chatStream.test.ts @@ -0,0 +1,81 @@ +import { streamCopilotChat } from "./chatStream"; +import { ReadableStream } from "stream/web"; +import { TextEncoder, TextDecoder } from "util"; + +if (!globalThis.ReadableStream) { + // @ts-expect-error test polyfill + globalThis.ReadableStream = ReadableStream; +} +if (!globalThis.TextEncoder) { + // @ts-expect-error test polyfill + globalThis.TextEncoder = TextEncoder; +} +if (!globalThis.TextDecoder) { + // @ts-expect-error test polyfill + globalThis.TextDecoder = TextDecoder; +} + +jest.mock("@/lib/apiFetch", () => ({ + apiFetch: jest.fn(), +})); + +const { apiFetch } = jest.requireMock("@/lib/apiFetch") as { + apiFetch: jest.Mock; +}; + +const makeStream = (chunks: string[]) => + new ReadableStream<Uint8Array>({ + start(controller) { + const encoder = new TextEncoder(); + chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))); + controller.close(); + }, + }); + +describe("streamCopilotChat", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("parses token and done events from chunked SSE", async () => { + apiFetch.mockResolvedValue({ + ok: true, + body: makeStream([ + 'event: token\ndata: {"conversationId":"c1","content":"he"}\n\n', + 'event: token\ndata: {"conversationId":"c1","content":"llo"}\n\n', + 'event: done\ndata: {"conversationId":"c1"}\n\n', + ]), + }); + + const events: Array<{ type: string; content?: string; conversationId?: string }> = []; + + await streamCopilotChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([ + { type: "token", conversationId: "c1", content: "he" }, + { type: "token", conversationId: "c1", content: "llo" }, + { type: "done", conversationId: "c1" }, + ]); + }); + + it("emits error when response is not ok", async () => { + apiFetch.mockResolvedValue({ + ok: false, + body: null, + text: async () => "bad request", + }); + + const events: Array<{ type: string; message?: string; detail?: string }> = []; + await streamCopilotChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([ + { type: "error", message: "stream request failed", detail: "bad request" }, + ]); + }); +}); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts new file mode 100644 index 0000000..a29feeb --- /dev/null +++ b/src/lib/chatStream.ts @@ -0,0 +1,115 @@ +import { apiFetch } from "@/lib/apiFetch"; +import { config } from "@config/config"; + +export type StreamEvent = + | { type: "token"; conversationId: string; content: string } + | { type: "done"; conversationId: string } + | { type: "error"; conversationId?: string; message: string; detail?: string }; + +type StreamOptions = { + message: string; + conversationId?: string; + signal?: AbortSignal; + onEvent: (event: StreamEvent) => void; +}; + +const parseEventBlock = (block: string): { event?: string; data?: string } => { + const lines = block.split("\n"); + let event: string | undefined; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trim()); + } + } + + return { + event, + data: dataLines.length ? dataLines.join("\n") : undefined, + }; +}; + +export const streamCopilotChat = async ({ + message, + conversationId, + signal, + onEvent, +}: StreamOptions) => { + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/copilot/chat/stream`, { + method: "POST", + signal, + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ + message, + conversation_id: conversationId, + }), + }); + + if (!response.ok || !response.body) { + const detail = await response.text(); + onEvent({ + type: "error", + message: "stream request failed", + detail, + }); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() ?? ""; + + for (const block of blocks) { + const { event, data } = parseEventBlock(block); + if (!event || !data) continue; + + try { + const parsed = JSON.parse(data) as { + conversationId?: string; + content?: string; + message?: string; + detail?: string; + }; + if (event === "token") { + onEvent({ + type: "token", + conversationId: parsed.conversationId ?? "", + content: parsed.content ?? "", + }); + } else if (event === "done") { + onEvent({ + type: "done", + conversationId: parsed.conversationId ?? "", + }); + } else if (event === "error") { + onEvent({ + type: "error", + conversationId: parsed.conversationId, + message: parsed.message ?? "unknown error", + detail: parsed.detail, + }); + } + } catch { + onEvent({ + type: "error", + message: "invalid SSE data payload", + detail: data, + }); + } + } + } +}; -- 2.54.0 From 045391d0367dd22ac972e366f1b15afa2abc3307 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 24 Mar 2026 10:56:25 +0800 Subject: [PATCH 058/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E8=AE=A4=E8=AF=81=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=EF=BC=9B=E6=B7=BB=E5=8A=A0=E8=81=8A=E5=A4=A9=E6=A1=86=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=95=88=E6=9E=9C=EF=BC=8C=E4=BC=98=E5=8C=96=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 547 +++++++++++++--------- package.json | 3 +- src/app/api/auth/[...nextauth]/options.ts | 75 ++- src/components/chat/GlobalChatbox.tsx | 517 ++++++++++++++++---- src/components/header/index.tsx | 19 +- src/lib/apiFetch.ts | 8 +- src/lib/chatStream.test.ts | 19 + src/lib/chatStream.ts | 13 +- src/types/next-auth.d.ts | 4 + 9 files changed, 879 insertions(+), 326 deletions(-) diff --git a/package-lock.json b/package-lock.json index c8bfce4..e5421a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "deck.gl": "^9.1.14", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", + "framer-motion": "^12.38.0", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", @@ -2582,9 +2583,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "license": "MIT", "optional": true, "dependencies": { @@ -2855,9 +2856,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3006,9 +3007,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, "engines": { @@ -3016,9 +3017,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", - "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -3034,13 +3035,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.3" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", - "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -3056,13 +3057,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -3076,9 +3077,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -3092,9 +3093,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", - "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -3108,9 +3109,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", - "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -3124,9 +3125,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", - "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -3139,10 +3140,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", - "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -3156,9 +3173,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", - "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -3172,9 +3189,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", - "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -3188,9 +3205,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", - "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -3204,9 +3221,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", - "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -3222,13 +3239,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.3" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", - "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -3244,13 +3261,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.3" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", - "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -3266,13 +3283,35 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.3" + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", - "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -3288,13 +3327,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.3" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", - "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -3310,13 +3349,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.3" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", - "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -3332,13 +3371,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", - "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -3354,20 +3393,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", - "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.5.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -3377,9 +3416,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", - "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -3396,9 +3435,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", - "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -3415,9 +3454,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", - "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -5381,9 +5420,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", + "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -5397,9 +5436,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", + "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", "cpu": [ "arm64" ], @@ -5413,9 +5452,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", "cpu": [ "x64" ], @@ -5429,9 +5468,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", "cpu": [ "arm64" ], @@ -5445,9 +5484,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", "cpu": [ "arm64" ], @@ -5461,9 +5500,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", "cpu": [ "x64" ], @@ -5477,9 +5516,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", "cpu": [ "x64" ], @@ -5493,9 +5532,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", "cpu": [ "arm64" ], @@ -5509,9 +5548,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", "cpu": [ "x64" ], @@ -7017,16 +7056,6 @@ } } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@turf/along": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz", @@ -9660,13 +9689,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -10053,9 +10082,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -10442,13 +10471,13 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -12219,9 +12248,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", - "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -13131,9 +13160,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -13453,10 +13482,10 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", @@ -13465,7 +13494,24 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -13754,9 +13800,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -13834,9 +13880,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -13858,6 +13904,33 @@ "node": ">= 0.6" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -14158,12 +14231,12 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -17941,9 +18014,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -18021,6 +18094,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -18090,14 +18178,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", + "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.2.1", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -18109,15 +18197,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -18920,6 +19008,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -19433,9 +19536,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -20273,6 +20376,16 @@ "postcss": "^8.3.11" } }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -20603,16 +20716,16 @@ } }, "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -20621,34 +20734,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "optional": true, "bin": { @@ -21237,9 +21352,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", @@ -21334,19 +21449,19 @@ "license": "MIT" }, "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", + "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", "dev": true, "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "sax": "^1.5.0" }, "bin": { "svgo": "bin/svgo" @@ -21421,9 +21536,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/package.json b/package.json index e2c26ec..8214974 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "deck.gl": "^9.1.14", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", + "framer-motion": "^12.38.0", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", @@ -53,7 +54,7 @@ "zustand": "^5.0.11" }, "overrides": { - "fast-xml-parser": "5.3.4" + "fast-xml-parser": "5.5.9" }, "devDependencies": { "@svgr/webpack": "^8.1.0", diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 0fb478e..45ac202 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -1,14 +1,58 @@ import { NextAuthOptions } from "next-auth"; +import { JWT } from "next-auth/jwt"; import KeycloakProvider from "next-auth/providers/keycloak"; import Avatar from "@assets/avatar/avatar-small.jpeg"; +type KeycloakTokenResponse = { + access_token: string; + expires_in: number; + refresh_token?: string; +}; + +const keycloakIssuer = process.env.KEYCLOAK_ISSUER!; +const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!; +const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!; +const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`; + +const refreshAccessToken = async (token: JWT): Promise<JWT> => { + if (!token.refreshToken) { + return { ...token, error: "RefreshAccessTokenError" }; + } + + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: keycloakClientId, + client_secret: keycloakClientSecret, + refresh_token: token.refreshToken, + }); + + const response = await fetch(keycloakTokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const refreshed = (await response.json()) as KeycloakTokenResponse; + + if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") { + return { ...token, error: "RefreshAccessTokenError" }; + } + + return { + ...token, + accessToken: refreshed.access_token, + accessTokenExpires: Date.now() + refreshed.expires_in * 1000, + refreshToken: refreshed.refresh_token ?? token.refreshToken, + error: undefined, + }; +}; + const authOptions: NextAuthOptions = { // Configure one or more authentication providers providers: [ KeycloakProvider({ - clientId: process.env.KEYCLOAK_CLIENT_ID!, - clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, - issuer: process.env.KEYCLOAK_ISSUER!, + clientId: keycloakClientId, + clientSecret: keycloakClientSecret, + issuer: keycloakIssuer, profile(profile) { return { id: profile.sub, @@ -25,10 +69,26 @@ const authOptions: NextAuthOptions = { if (profile?.sub) { token.sub = profile.sub; } - if (account?.access_token) { - token.accessToken = account.access_token; + + if (account) { + if (account.access_token) { + token.accessToken = account.access_token; + } + if (account.refresh_token) { + token.refreshToken = account.refresh_token; + } + if (typeof account.expires_at === "number") { + token.accessTokenExpires = account.expires_at * 1000; + } + token.error = undefined; + return token; } - return token; + + if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) { + return token; + } + + return refreshAccessToken(token); }, session: async ({ session, token }) => { if (session.user && token.sub) { @@ -37,6 +97,9 @@ const authOptions: NextAuthOptions = { if (token.accessToken) { session.accessToken = token.accessToken; } + if (token.error) { + session.error = token.error; + } return session; }, }, diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 7153a20..51f7b2a 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -1,23 +1,35 @@ "use client"; -import ChatOutlined from "@mui/icons-material/ChatOutlined"; -import Close from "@mui/icons-material/Close"; -import Send from "@mui/icons-material/Send"; +import React, { useMemo, useRef, useState, useEffect } from "react"; +import ReactMarkdown from "react-markdown"; +import { motion, AnimatePresence } from "framer-motion"; + +// MUI import { + Avatar, Box, - CircularProgress, Drawer, IconButton, - List, - ListItem, - ListItemText, + Paper, Stack, TextField, Typography, + useTheme, + alpha, + Tooltip, } from "@mui/material"; -import React, { useMemo, useRef, useState } from "react"; + +// Icons +import CloseRounded from "@mui/icons-material/CloseRounded"; +import SendRounded from "@mui/icons-material/SendRounded"; +import StopRounded from "@mui/icons-material/StopRounded"; +import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI +import PersonRounded from "@mui/icons-material/PersonRounded"; + +// Logic import { streamCopilotChat } from "@/lib/chatStream"; +// Types type Message = { id: string; role: "user" | "assistant"; @@ -29,17 +41,87 @@ type Props = { onClose: () => void; }; +// Utils const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +// --- Components --- + +const TypingIndicator = () => { + return ( + <Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}> + {[0, 1, 2].map((i) => ( + <motion.div + key={i} + initial={{ y: 0 }} + animate={{ y: [-4, 4, -4] }} + transition={{ + duration: 0.6, + repeat: Infinity, + delay: i * 0.15, + ease: "easeInOut", // Smooth sine wave + }} + > + <Box + sx={{ + width: 8, + height: 8, + borderRadius: "50%", + background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)", // Warm gradient dots + }} + /> + </motion.div> + ))} + </Stack> + ); +}; + +// Animated Background Blob +const Blob = ({ color, size, top, left, delay }: { color: string; size: number; top: string; left: string; delay: number }) => ( + <motion.div + initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }} + animate={{ + scale: [0.8, 1.2, 0.8], + opacity: [0.3, 0.5, 0.3], + x: [0, 30, 0], + y: [0, -30, 0], + }} + transition={{ + duration: 8, + repeat: Infinity, + ease: "easeInOut", + delay: delay, + }} + style={{ + position: "absolute", + top, + left, + width: size, + height: size, + borderRadius: "50%", + background: color, + filter: "blur(60px)", + zIndex: 0, + pointerEvents: "none", + }} + /> +); + export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [conversationId, setConversationId] = useState<string | undefined>(undefined); const abortRef = useRef<AbortController | null>(null); + const bottomRef = useRef<HTMLDivElement>(null); + const theme = useTheme(); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); + // Auto-scroll + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isStreaming]); + const handleSend = async () => { const prompt = input.trim(); if (!prompt || isStreaming) return; @@ -65,45 +147,31 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { signal: controller.signal, onEvent: (event) => { if (event.type === "token") { - if (!conversationId && event.conversationId) { - setConversationId(event.conversationId); - } + if (!conversationId && event.conversationId) setConversationId(event.conversationId); setMessages((prev) => - prev.map((item) => - item.id === assistantId - ? { ...item, content: `${item.content}${event.content}` } - : item, - ), + prev.map((m) => + m.id === assistantId ? { ...m, content: m.content + event.content } : m + ) ); } else if (event.type === "done") { - if (!conversationId && event.conversationId) { - setConversationId(event.conversationId); - } + if (!conversationId && event.conversationId) setConversationId(event.conversationId); setIsStreaming(false); } else if (event.type === "error") { setMessages((prev) => - prev.map((item) => - item.id === assistantId - ? { - ...item, - content: - item.content || - `Error: ${event.message}${event.detail ? ` (${event.detail})` : ""}`, - } - : item, - ), + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content || `错误:${event.message}` } + : m + ) ); setIsStreaming(false); } }, }); } catch (error) { + if (abortRef.current?.signal.aborted) return; setMessages((prev) => - prev.map((item) => - item.id === assistantId - ? { ...item, content: `Error: ${String(error)}` } - : item, - ), + prev.map((m) => (m.id === assistantId ? { ...m, content: `错误:${String(error)}` } : m)) ); setIsStreaming(false); } finally { @@ -118,66 +186,335 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }; return ( - <Drawer anchor="right" open={open} onClose={onClose}> - <Box sx={{ width: { xs: "100vw", sm: 420 }, height: "100%", display: "flex", flexDirection: "column" }}> - <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ p: 2, borderBottom: "1px solid", borderColor: "divider" }}> - <Stack direction="row" alignItems="center" spacing={1}> - <ChatOutlined /> - <Typography variant="subtitle1" fontWeight={600}> - Copilot Chat - </Typography> + <Drawer + anchor="right" + open={open} + onClose={onClose} + sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }} + PaperProps={{ + sx: { + width: { xs: "100%", sm: 480 }, + background: "transparent", + boxShadow: "none", + overflow: "hidden", // Clip blobs + zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, + }, + }} + ModalProps={{ + BackdropProps: { + sx: { backdropFilter: "blur(6px)", bgcolor: alpha(theme.palette.background.default, 0.3) }, + }, + }} + > + <Box + sx={{ + height: "100%", + display: "flex", + flexDirection: "column", + bgcolor: alpha("#fff", 0.75), // Light glass base + backdropFilter: "blur(30px)", + position: "relative", + }} + > + {/* Ambient Blobs */} + <Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} /> + <Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} /> + <Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} /> + + {/* Header - Transparent & Floating */} + <Box + sx={{ + p: 3, + zIndex: 10, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }} + > + <Stack direction="row" alignItems="center" spacing={2}> + <motion.div + whileHover={{ rotate: 10, scale: 1.1 }} + whileTap={{ scale: 0.95 }} + > + <Box sx={{ position: "relative" }}> + <Avatar + sx={{ + background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`, + boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`, + width: 48, + height: 48, + }} + > + <AutoAwesome fontSize="medium" sx={{ color: "#fff" }} /> + </Avatar> + <Box + sx={{ + position: "absolute", + bottom: 2, + right: 2, + width: 12, + height: 12, + bgcolor: "success.main", + borderRadius: "50%", + border: "2px solid #fff", + boxShadow: "0 0 0 2px rgba(255,255,255,0.5)" + }} + /> + </Box> + </motion.div> + + <Box> + <Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}> + Copilot + </Typography> + <Typography variant="caption" color="text.secondary" fontWeight={500}> + 你的 AI 助手 + </Typography> + </Box> </Stack> - <IconButton onClick={onClose} size="small"> - <Close /> - </IconButton> - </Stack> + + <motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}> + <IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}> + <CloseRounded /> + </IconButton> + </motion.div> + </Box> - <List sx={{ flex: 1, overflow: "auto", px: 1.5 }}> - {messages.map((message) => ( - <ListItem key={message.id} sx={{ justifyContent: message.role === "user" ? "flex-end" : "flex-start" }}> - <Box + {/* Messages - Bouncy List */} + <Box sx={{ flex: 1, overflowY: "auto", px: 2.5, py: 2, display: "flex", flexDirection: "column", gap: 2.5, zIndex: 5 }}> + <AnimatePresence initial={false}> + {messages.length === 0 && ( + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ type: "spring", stiffness: 200, damping: 20 }} + style={{ margin: "auto", width: "100%" }} + > + <Paper + elevation={0} + sx={{ + p: 4, + borderRadius: 6, + bgcolor: alpha("#fff", 0.6), + border: `1px solid ${alpha(theme.palette.divider, 0.1)}`, + maxWidth: 320, + mx: "auto", + textAlign: "center", + backdropFilter: "blur(10px)", + }} + > + <motion.div + animate={{ y: [-5, 5, -5] }} + transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }} + > + <AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} /> + </motion.div> + <Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom> + 你好呀!👋 + </Typography> + <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}> + 我已准备好为你提供帮助,尽管问我吧! + </Typography> + </Paper> + </motion.div> + )} + + {messages.map((message) => { + const isUser = message.role === "user"; + return ( + <motion.div + key={message.id} + initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ type: "spring", stiffness: 350, damping: 25 }} + style={{ + alignSelf: isUser ? "flex-end" : "flex-start", + maxWidth: "85%", + display: "flex", + flexDirection: isUser ? "row-reverse" : "row", + gap: 12, + alignItems: "flex-end", + }} + > + {!isUser && ( + <Avatar sx={{ width: 28, height: 28, bgcolor: alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}> + <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> + </Avatar> + )} + + <Paper + elevation={isUser ? 8 : 2} + sx={{ + p: 2.5, + borderRadius: 4, + borderBottomRightRadius: isUser ? 4 : 24, + borderBottomLeftRadius: !isUser ? 4 : 24, + bgcolor: isUser ? "primary.main" : "#fff", + color: isUser ? "#fff" : "text.primary", + background: isUser ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` : undefined, + boxShadow: isUser + ? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}` + : `0 4px 16px -4px ${alpha("#000", 0.05)}`, + + // Markdown Styles + "& p": { m: 0, lineHeight: 1.6 }, + "& code": { + fontFamily: "monospace", + bgcolor: isUser ? "rgba(255,255,255,0.2)" : alpha(theme.palette.grey[100], 0.8), + px: 0.8, + py: 0.2, + borderRadius: 1, + fontSize: "0.85em", + border: isUser ? "none" : `1px solid ${alpha(theme.palette.divider, 0.1)}`, + }, + "& pre": { + bgcolor: isUser ? "rgba(0,0,0,0.25)" : "#222", + color: "#f8f8f2", + p: 2, + borderRadius: 3, + overflowX: "auto", + my: 1.5, + fontSize: "0.85em", + border: "1px solid rgba(255,255,255,0.1)", + }, + "& ul, & ol": { pl: 2.5, my: 1 }, + }} + > + {isUser ? ( + <Typography variant="body2" fontSize="0.95rem" sx={{ whiteSpace: "pre-wrap" }}>{message.content}</Typography> + ) : ( + <ReactMarkdown>{message.content || "..."}</ReactMarkdown> + )} + </Paper> + </motion.div> + ); + })} + </AnimatePresence> + + {isStreaming && ( + <motion.div + initial={{ opacity: 0, y: 10, scale: 0.9 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + transition={{ type: "spring", stiffness: 300 }} + style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 40 }} + > + <Paper + elevation={0} + sx={{ + p: 1.5, + borderRadius: 4, + bgcolor: alpha("#fff", 0.8), + boxShadow: `0 4px 12px ${alpha("#000", 0.05)}` + }} + > + <TypingIndicator /> + </Paper> + </motion.div> + )} + + <div ref={bottomRef} style={{ height: 1 }} /> + </Box> + + {/* Input Area - Floating Capsule */} + <Box sx={{ p: 3, zIndex: 10 }}> + <motion.div + initial={{ y: 20, opacity: 0 }} + animate={{ y: 0, opacity: 1 }} + transition={{ delay: 0.2 }} + > + <Stack + direction="row" + alignItems="center" + component={Paper} + elevation={12} sx={{ - maxWidth: "86%", - px: 1.5, - py: 1, - borderRadius: 2, - bgcolor: message.role === "user" ? "primary.main" : "grey.100", - color: message.role === "user" ? "primary.contrastText" : "text.primary", - whiteSpace: "pre-wrap", + p: "6px 8px", + borderRadius: 50, // Full capsule + bgcolor: alpha("#fff", 0.9), + backdropFilter: "blur(10px)", + border: `1px solid ${alpha("#fff", 0.6)}`, + boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`, + transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", + "&:hover": { + transform: "translateY(-2px)", + boxShadow: `0 16px 48px -8px ${alpha(theme.palette.primary.main, 0.25)}`, + } }} - > - <ListItemText primaryTypographyProps={{ variant: "body2" }} primary={message.content || "..."} /> - </Box> - </ListItem> - ))} - </List> - - <Stack direction="row" spacing={1} sx={{ p: 1.5, borderTop: "1px solid", borderColor: "divider" }}> - <TextField - value={input} - onChange={(e) => setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - void handleSend(); - } - }} - size="small" - multiline - maxRows={4} - fullWidth - placeholder="输入消息..." - /> - {isStreaming ? ( - <IconButton color="warning" onClick={handleAbort}> - <CircularProgress size={20} /> - </IconButton> - ) : ( - <IconButton color="primary" disabled={!canSend} onClick={() => void handleSend()}> - <Send /> - </IconButton> - )} - </Stack> + > + <TextField + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }} + placeholder="输入消息给 Copilot..." + fullWidth + multiline + maxRows={3} + variant="standard" + InputProps={{ + disableUnderline: true, + sx: { px: 2.5, py: 1.5, fontSize: "1rem" }, + }} + /> + + <Box sx={{ pr: 0.5 }}> + <AnimatePresence mode="wait"> + {isStreaming ? ( + <motion.div + key="stop" + initial={{ scale: 0, rotate: -180 }} + animate={{ scale: 1, rotate: 0 }} + exit={{ scale: 0, rotate: 180 }} + transition={{ type: "spring", stiffness: 400, damping: 25 }} + > + <IconButton + onClick={handleAbort} + sx={{ + bgcolor: alpha(theme.palette.error.main, 0.1), + color: "error.main", + width: 44, height: 44, + "&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) } + }} + > + <StopRounded /> + </IconButton> + </motion.div> + ) : ( + <motion.div + key="send" + initial={{ scale: 0 }} + animate={{ scale: 1 }} + exit={{ scale: 0 }} + transition={{ type: "spring", stiffness: 400, damping: 25 }} + > + <IconButton + disabled={!canSend} + onClick={() => void handleSend()} + sx={{ + bgcolor: canSend ? "primary.main" : "action.disabledBackground", + color: "#fff", + width: 44, height: 44, + transition: "background-color 0.2s", + "&:hover": { + bgcolor: "primary.dark", + boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.5)}` + } + }} + > + <SendRounded sx={{ ml: 0.5 }} /> + </IconButton> + </motion.div> + )} + </AnimatePresence> + </Box> + </Stack> + </motion.div> + </Box> </Box> </Drawer> ); diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 14d14d5..4c40372 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -3,6 +3,7 @@ import { ColorModeContext } from "@contexts/color-mode"; import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined"; import LightModeOutlined from "@mui/icons-material/LightModeOutlined"; +import { IoChatbubbleEllipsesOutline } from "react-icons/io5"; import Logout from "@mui/icons-material/Logout"; import SwapHoriz from "@mui/icons-material/SwapHoriz"; import ChatOutlined from "@mui/icons-material/ChatOutlined"; @@ -94,24 +95,24 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ justifyContent="flex-end" alignItems="center" > - <IconButton - color="inherit" - onClick={() => setShowChatbox(true)} - > - <ChatOutlined /> - </IconButton> - - <IconButton + {/* <IconButton color="inherit" onClick={() => { setMode(); }} > {mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />} - </IconButton> + </IconButton> */} {(user?.avatar || user?.name) && ( <> + <IconButton + color="inherit" + onClick={() => setShowChatbox(true)} + sx={{ mr: 1 }} + > + <IoChatbubbleEllipsesOutline /> + </IconButton> <ButtonBase onClick={handleMenuOpen} sx={{ diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts index 08cdf49..7d8dd14 100644 --- a/src/lib/apiFetch.ts +++ b/src/lib/apiFetch.ts @@ -15,9 +15,13 @@ const resolveUrl = (input: RequestInfo | URL) => { const isMetaProjectsRequest = (input: RequestInfo | URL) => resolveUrl(input).includes("/api/v1/meta/projects"); +export interface ApiFetchInit extends RequestInit { + skipAuthRedirect?: boolean; +} + export const apiFetch = async ( input: RequestInfo | URL, - init: RequestInit = {}, + init: ApiFetchInit = {}, ) => { const projectId = useProjectStore.getState().currentProjectId; const headers = new Headers(init.headers ?? {}); @@ -31,7 +35,7 @@ export const apiFetch = async ( const response = await fetch(input, { ...init, headers }); - if (response.status === 401 && typeof window !== "undefined") { + if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) { useAuthStore.getState().setAccessToken(null); if (!isSigningOut) { isSigningOut = true; diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 9956de2..b4bff7f 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -78,4 +78,23 @@ describe("streamCopilotChat", () => { { type: "error", message: "stream request failed", detail: "bad request" }, ]); }); + + it("emits re-login message on unauthorized response", async () => { + apiFetch.mockResolvedValue({ + ok: false, + status: 401, + body: null, + text: async () => "unauthorized", + }); + + const events: Array<{ type: string; message?: string; detail?: string }> = []; + await streamCopilotChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([ + { type: "error", message: "Login expired. Please sign in again.", detail: undefined }, + ]); + }); }); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index a29feeb..25492ee 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -49,14 +49,23 @@ export const streamCopilotChat = async ({ message, conversation_id: conversationId, }), + skipAuthRedirect: true, }); if (!response.ok || !response.body) { const detail = await response.text(); + let message = "stream request failed"; + + if (response.status === 403) { + message = "Permission denied. Please contact administrator."; + } else if (response.status === 401) { + message = "Login expired. Please sign in again."; + } + onEvent({ type: "error", - message: "stream request failed", - detail, + message, + detail: (response.status === 403 || response.status === 401) ? undefined : detail, }); return; } diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 233d00a..b67a3a9 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -4,6 +4,7 @@ import "next-auth/jwt"; declare module "next-auth" { interface Session { accessToken?: string; + error?: "RefreshAccessTokenError"; user?: { id?: string; name?: string | null; @@ -21,5 +22,8 @@ declare module "next-auth/jwt" { interface JWT { sub?: string; accessToken?: string; + refreshToken?: string; + accessTokenExpires?: number; + error?: "RefreshAccessTokenError"; } } -- 2.54.0 From 825acbf29ca50c2c161e334ab1fc5ffe3173542a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 24 Mar 2026 16:25:09 +0800 Subject: [PATCH 059/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=86=E8=BE=93=E5=85=A5=E8=81=9A=E7=84=A6=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=BD=91=E7=BB=9C=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 10 ++++++++ src/lib/chatStream.test.ts | 14 ++++++++++ src/lib/chatStream.ts | 37 +++++++++++++++++---------- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 51f7b2a..0ac1d9d 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -113,6 +113,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [conversationId, setConversationId] = useState<string | undefined>(undefined); const abortRef = useRef<AbortController | null>(null); const bottomRef = useRef<HTMLDivElement>(null); + const inputRef = useRef<HTMLInputElement | null>(null); const theme = useTheme(); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); @@ -122,6 +123,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isStreaming]); + useEffect(() => { + if (!open) return; + const timer = window.setTimeout(() => { + inputRef.current?.focus(); + }, 0); + return () => window.clearTimeout(timer); + }, [open]); + const handleSend = async () => { const prompt = input.trim(); if (!prompt || isStreaming) return; @@ -443,6 +452,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }} > <TextField + inputRef={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index b4bff7f..d477ce3 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -97,4 +97,18 @@ describe("streamCopilotChat", () => { { type: "error", message: "Login expired. Please sign in again.", detail: undefined }, ]); }); + + it("emits network error when fetch throws", async () => { + apiFetch.mockRejectedValue(new TypeError("Failed to fetch")); + + const events: Array<{ type: string; message?: string; detail?: string }> = []; + await streamCopilotChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([ + { type: "error", message: "network request failed", detail: "Failed to fetch" }, + ]); + }); }); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 25492ee..b848f6d 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -38,19 +38,30 @@ export const streamCopilotChat = async ({ signal, onEvent, }: StreamOptions) => { - const response = await apiFetch(`${config.BACKEND_URL}/api/v1/copilot/chat/stream`, { - method: "POST", - signal, - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - }, - body: JSON.stringify({ - message, - conversation_id: conversationId, - }), - skipAuthRedirect: true, - }); + let response: Response; + try { + response = await apiFetch(`${config.BACKEND_URL}/api/v1/copilot/chat/stream`, { + method: "POST", + signal, + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ + message, + conversation_id: conversationId, + }), + skipAuthRedirect: true, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + onEvent({ + type: "error", + message: "network request failed", + detail, + }); + return; + } if (!response.ok || !response.body) { const detail = await response.text(); -- 2.54.0 From 03a77f7368570f86db56b78f2bf6408cb7384117 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 24 Mar 2026 16:44:19 +0800 Subject: [PATCH 060/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=86=E7=8A=B6=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=9B=E4=BC=98=E5=8C=96=E4=BF=A1=E6=81=AF=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 172 ++++++++++++++++++++++---- 1 file changed, 151 insertions(+), 21 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 0ac1d9d..35aebc0 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -25,6 +25,7 @@ import SendRounded from "@mui/icons-material/SendRounded"; import StopRounded from "@mui/icons-material/StopRounded"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI import PersonRounded from "@mui/icons-material/PersonRounded"; +import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; @@ -34,6 +35,7 @@ type Message = { id: string; role: "user" | "assistant"; content: string; + isError?: boolean; }; type Props = { @@ -43,6 +45,12 @@ type Props = { // Utils const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1"; + +type PersistedChatState = { + messages: Message[]; + conversationId?: string; +}; // --- Components --- @@ -114,6 +122,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const abortRef = useRef<AbortController | null>(null); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); + const hasHydratedRef = useRef(false); const theme = useTheme(); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); @@ -131,6 +140,40 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { return () => window.clearTimeout(timer); }, [open]); + useEffect(() => { + try { + const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); + if (!storedRaw) { + hasHydratedRef.current = true; + return; + } + const parsed = JSON.parse(storedRaw) as PersistedChatState; + if (!Array.isArray(parsed.messages)) { + console.error("[GlobalChatbox] Invalid persisted messages format."); + window.localStorage.removeItem(CHAT_STORAGE_KEY); + hasHydratedRef.current = true; + return; + } + setMessages(parsed.messages); + setConversationId(parsed.conversationId); + } catch (error) { + console.error("[GlobalChatbox] Failed to read persisted chat state:", error); + window.localStorage.removeItem(CHAT_STORAGE_KEY); + } finally { + hasHydratedRef.current = true; + } + }, []); + + useEffect(() => { + if (!hasHydratedRef.current) return; + const state: PersistedChatState = { messages, conversationId }; + try { + window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.error("[GlobalChatbox] Failed to persist chat state:", error); + } + }, [messages, conversationId]); + const handleSend = async () => { const prompt = input.trim(); if (!prompt || isStreaming) return; @@ -159,7 +202,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { if (!conversationId && event.conversationId) setConversationId(event.conversationId); setMessages((prev) => prev.map((m) => - m.id === assistantId ? { ...m, content: m.content + event.content } : m + m.id === assistantId + ? { ...m, content: m.content + event.content, isError: false } + : m ) ); } else if (event.type === "done") { @@ -169,7 +214,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { setMessages((prev) => prev.map((m) => m.id === assistantId - ? { ...m, content: m.content || `错误:${event.message}` } + ? { + ...m, + content: m.content || `⚠️ **错误:** ${event.message}`, + isError: true, + } : m ) ); @@ -180,7 +229,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { } catch (error) { if (abortRef.current?.signal.aborted) return; setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: `错误:${String(error)}` } : m)) + prev.map((m) => + m.id === assistantId + ? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true } + : m + ) ); setIsStreaming(false); } finally { @@ -330,6 +383,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { {messages.map((message) => { const isUser = message.role === "user"; + const isErrorMessage = Boolean(message.isError); return ( <motion.div key={message.id} @@ -347,54 +401,130 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }} > {!isUser && ( - <Avatar sx={{ width: 28, height: 28, bgcolor: alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}> - <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> + <Avatar sx={{ width: 28, height: 28, bgcolor: isErrorMessage ? alpha(theme.palette.error.main, 0.12) : alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}> + {isErrorMessage ? ( + <ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} /> + ) : ( + <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> + )} </Avatar> )} <Paper - elevation={isUser ? 8 : 2} + elevation={isUser ? 8 : isErrorMessage ? 1 : 2} sx={{ p: 2.5, borderRadius: 4, borderBottomRightRadius: isUser ? 4 : 24, borderBottomLeftRadius: !isUser ? 4 : 24, - bgcolor: isUser ? "primary.main" : "#fff", - color: isUser ? "#fff" : "text.primary", - background: isUser ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` : undefined, + bgcolor: isUser ? "primary.main" : isErrorMessage ? alpha(theme.palette.error.light, 0.18) : "#fff", + color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary", + background: isUser + ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` + : isErrorMessage + ? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})` + : undefined, + border: isErrorMessage ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` : "none", boxShadow: isUser ? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}` + : isErrorMessage + ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` : `0 4px 16px -4px ${alpha("#000", 0.05)}`, // Markdown Styles - "& p": { m: 0, lineHeight: 1.6 }, + "& p": { + m: 0, + lineHeight: 1.75, + whiteSpace: "pre-wrap", + color: isUser ? alpha("#fff", 0.96) : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary, + }, + "& h1, & h2, & h3, & h4, & h5, & h6": { + mt: 0.6, + mb: 0.6, + lineHeight: 1.35, + fontWeight: 700, + color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary, + }, + "& h1": { fontSize: "1.2rem" }, + "& h2": { fontSize: "1.12rem" }, + "& h3": { fontSize: "1.04rem" }, + "& a": { + color: isUser ? "#E3F2FD" : isErrorMessage ? theme.palette.error.main : theme.palette.primary.dark, + textDecoration: "underline", + wordBreak: "break-all", + textUnderlineOffset: "2px", + "&:hover": { + color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.primary.main, + }, + }, "& code": { fontFamily: "monospace", - bgcolor: isUser ? "rgba(255,255,255,0.2)" : alpha(theme.palette.grey[100], 0.8), + bgcolor: isUser + ? "rgba(255,255,255,0.2)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : alpha(theme.palette.grey[100], 0.95), + color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : alpha(theme.palette.text.primary, 0.95), px: 0.8, py: 0.2, borderRadius: 1, fontSize: "0.85em", - border: isUser ? "none" : `1px solid ${alpha(theme.palette.divider, 0.1)}`, + border: isUser + ? "none" + : isErrorMessage + ? `1px solid ${alpha(theme.palette.error.main, 0.25)}` + : `1px solid ${alpha(theme.palette.divider, 0.35)}`, }, "& pre": { - bgcolor: isUser ? "rgba(0,0,0,0.25)" : "#222", - color: "#f8f8f2", + bgcolor: isUser + ? "rgba(11, 18, 32, 0.56)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#0F172A", + color: isUser + ? "#F8FAFC" + : isErrorMessage + ? theme.palette.error.dark + : "#E2E8F0", p: 2, borderRadius: 3, overflowX: "auto", my: 1.5, - fontSize: "0.85em", - border: "1px solid rgba(255,255,255,0.1)", + fontSize: "0.88em", + border: isErrorMessage + ? `1px solid ${alpha(theme.palette.error.main, 0.3)}` + : `1px solid ${isUser ? alpha("#fff", 0.12) : alpha("#94A3B8", 0.35)}`, + }, + "& pre code": { + bgcolor: "transparent", + border: "none", + px: 0, + py: 0, + color: "inherit", }, "& ul, & ol": { pl: 2.5, my: 1 }, + "& li": { + my: 0.35, + lineHeight: 1.65, + }, + "& blockquote": { + m: 0, + my: 1, + pl: 1.5, + borderLeft: `3px solid ${isErrorMessage ? alpha(theme.palette.error.main, 0.5) : alpha(theme.palette.divider, 0.5)}`, + color: isUser ? alpha("#fff", 0.9) : isErrorMessage ? theme.palette.error.dark : "text.secondary", + bgcolor: isUser + ? alpha("#fff", 0.08) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.06) + : alpha(theme.palette.grey[100], 0.7), + borderRadius: 1, + py: 0.5, + pr: 1, + }, }} > - {isUser ? ( - <Typography variant="body2" fontSize="0.95rem" sx={{ whiteSpace: "pre-wrap" }}>{message.content}</Typography> - ) : ( - <ReactMarkdown>{message.content || "..."}</ReactMarkdown> - )} + <ReactMarkdown>{message.content || "..."}</ReactMarkdown> </Paper> </motion.div> ); -- 2.54.0 From 8713e5a46823fe7922d7e77583fa030cd10db7ec Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 26 Mar 2026 11:55:19 +0800 Subject: [PATCH 061/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=86=E7=8A=B6=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Markdown=20=E6=A0=B7=E5=BC=8F=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=9B=E8=B0=83=E6=95=B4=E5=9C=B0=E5=9B=BE=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E5=B1=82=E7=BA=A7=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=92=8C=E8=81=8A=E5=A4=A9=E6=A1=86=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 235 ++++++++---------- .../chat/GlobalChatboxMarkdown.module.css | 90 +++++++ .../olmap/core/Controls/BaseLayers.tsx | 2 +- .../olmap/core/Controls/LayerControl.tsx | 2 +- .../olmap/core/Controls/ScaleLine.tsx | 2 +- src/components/olmap/core/Controls/Zoom.tsx | 2 +- 6 files changed, 202 insertions(+), 131 deletions(-) create mode 100644 src/components/chat/GlobalChatboxMarkdown.module.css diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 35aebc0..4261894 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useRef, useState, useEffect } from "react"; import ReactMarkdown from "react-markdown"; import { motion, AnimatePresence } from "framer-motion"; +import markdownStyles from "./GlobalChatboxMarkdown.module.css"; // MUI import { @@ -52,6 +53,27 @@ type PersistedChatState = { conversationId?: string; }; +const getInitialChatState = (): PersistedChatState => { + if (typeof window === "undefined") { + return { messages: [], conversationId: undefined }; + } + try { + const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); + if (!storedRaw) return { messages: [], conversationId: undefined }; + const parsed = JSON.parse(storedRaw) as PersistedChatState; + if (!Array.isArray(parsed.messages)) { + console.error("[GlobalChatbox] Invalid persisted messages format."); + window.localStorage.removeItem(CHAT_STORAGE_KEY); + return { messages: [], conversationId: undefined }; + } + return { messages: parsed.messages, conversationId: parsed.conversationId }; + } catch (error) { + console.error("[GlobalChatbox] Failed to read persisted chat state:", error); + window.localStorage.removeItem(CHAT_STORAGE_KEY); + return { messages: [], conversationId: undefined }; + } +}; + // --- Components --- const TypingIndicator = () => { @@ -115,14 +137,20 @@ const Blob = ({ color, size, top, left, delay }: { color: string; size: number; ); export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { - const [messages, setMessages] = useState<Message[]>([]); + const initialChatStateRef = useRef<PersistedChatState | null>(null); + if (initialChatStateRef.current === null) { + initialChatStateRef.current = getInitialChatState(); + } + + const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); - const [conversationId, setConversationId] = useState<string | undefined>(undefined); + const [conversationId, setConversationId] = useState<string | undefined>( + initialChatStateRef.current.conversationId + ); const abortRef = useRef<AbortController | null>(null); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); - const hasHydratedRef = useRef(false); const theme = useTheme(); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); @@ -136,36 +164,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { if (!open) return; const timer = window.setTimeout(() => { inputRef.current?.focus(); + bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, 0); return () => window.clearTimeout(timer); }, [open]); useEffect(() => { - try { - const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); - if (!storedRaw) { - hasHydratedRef.current = true; - return; - } - const parsed = JSON.parse(storedRaw) as PersistedChatState; - if (!Array.isArray(parsed.messages)) { - console.error("[GlobalChatbox] Invalid persisted messages format."); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - hasHydratedRef.current = true; - return; - } - setMessages(parsed.messages); - setConversationId(parsed.conversationId); - } catch (error) { - console.error("[GlobalChatbox] Failed to read persisted chat state:", error); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - } finally { - hasHydratedRef.current = true; - } - }, []); - - useEffect(() => { - if (!hasHydratedRef.current) return; const state: PersistedChatState = { messages, conversationId }; try { window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); @@ -227,7 +231,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }, }); } catch (error) { - if (abortRef.current?.signal.aborted) return; + if (abortRef.current?.signal.aborted) { + setMessages((prev) => + prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0)) + ); + return; + } setMessages((prev) => prev.map((m) => m.id === assistantId @@ -250,8 +259,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { return ( <Drawer anchor="right" + variant="persistent" open={open} onClose={onClose} + hideBackdrop sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }} PaperProps={{ sx: { @@ -262,11 +273,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, }, }} - ModalProps={{ - BackdropProps: { - sx: { backdropFilter: "blur(6px)", bgcolor: alpha(theme.palette.background.default, 0.3) }, - }, - }} > <Box sx={{ @@ -430,101 +436,76 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { : isErrorMessage ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` : `0 4px 16px -4px ${alpha("#000", 0.05)}`, - - // Markdown Styles - "& p": { - m: 0, - lineHeight: 1.75, - whiteSpace: "pre-wrap", - color: isUser ? alpha("#fff", 0.96) : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary, - }, - "& h1, & h2, & h3, & h4, & h5, & h6": { - mt: 0.6, - mb: 0.6, - lineHeight: 1.35, - fontWeight: 700, - color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary, - }, - "& h1": { fontSize: "1.2rem" }, - "& h2": { fontSize: "1.12rem" }, - "& h3": { fontSize: "1.04rem" }, - "& a": { - color: isUser ? "#E3F2FD" : isErrorMessage ? theme.palette.error.main : theme.palette.primary.dark, - textDecoration: "underline", - wordBreak: "break-all", - textUnderlineOffset: "2px", - "&:hover": { - color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.primary.main, - }, - }, - "& code": { - fontFamily: "monospace", - bgcolor: isUser - ? "rgba(255,255,255,0.2)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : alpha(theme.palette.grey[100], 0.95), - color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : alpha(theme.palette.text.primary, 0.95), - px: 0.8, - py: 0.2, - borderRadius: 1, - fontSize: "0.85em", - border: isUser - ? "none" - : isErrorMessage - ? `1px solid ${alpha(theme.palette.error.main, 0.25)}` - : `1px solid ${alpha(theme.palette.divider, 0.35)}`, - }, - "& pre": { - bgcolor: isUser - ? "rgba(11, 18, 32, 0.56)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#0F172A", - color: isUser - ? "#F8FAFC" - : isErrorMessage - ? theme.palette.error.dark - : "#E2E8F0", - p: 2, - borderRadius: 3, - overflowX: "auto", - my: 1.5, - fontSize: "0.88em", - border: isErrorMessage - ? `1px solid ${alpha(theme.palette.error.main, 0.3)}` - : `1px solid ${isUser ? alpha("#fff", 0.12) : alpha("#94A3B8", 0.35)}`, - }, - "& pre code": { - bgcolor: "transparent", - border: "none", - px: 0, - py: 0, - color: "inherit", - }, - "& ul, & ol": { pl: 2.5, my: 1 }, - "& li": { - my: 0.35, - lineHeight: 1.65, - }, - "& blockquote": { - m: 0, - my: 1, - pl: 1.5, - borderLeft: `3px solid ${isErrorMessage ? alpha(theme.palette.error.main, 0.5) : alpha(theme.palette.divider, 0.5)}`, - color: isUser ? alpha("#fff", 0.9) : isErrorMessage ? theme.palette.error.dark : "text.secondary", - bgcolor: isUser - ? alpha("#fff", 0.08) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.06) - : alpha(theme.palette.grey[100], 0.7), - borderRadius: 1, - py: 0.5, - pr: 1, - }, + "--chat-md-text": isUser + ? alpha("#fff", 0.96) + : isErrorMessage + ? theme.palette.error.dark + : "#1f2937", + "--chat-md-heading": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#111827", + "--chat-md-link": isUser + ? "#E3F2FD" + : isErrorMessage + ? theme.palette.error.main + : "#7C3AED", + "--chat-md-link-hover": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#6D28D9", + "--chat-md-inline-code-bg": isUser + ? "rgba(255,255,255,0.2)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#EEF2FF", + "--chat-md-inline-code-border": isUser + ? alpha("#fff", 0.16) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.25) + : "#CBD5E1", + "--chat-md-inline-code-text": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#334155", + "--chat-md-pre-bg": isUser + ? "rgba(11, 18, 32, 0.56)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#111827", + "--chat-md-pre-border": isUser + ? alpha("#fff", 0.12) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.3) + : "#64748B", + "--chat-md-pre-text": isUser + ? "#F8FAFC" + : isErrorMessage + ? theme.palette.error.dark + : "#E5E7EB", + "--chat-md-quote-border": isErrorMessage + ? alpha(theme.palette.error.main, 0.5) + : isUser + ? alpha("#fff", 0.5) + : "#7C3AED", + "--chat-md-quote-bg": isUser + ? alpha("#fff", 0.08) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.06) + : "#F5F3FF", + "--chat-md-quote-text": isUser + ? alpha("#fff", 0.9) + : isErrorMessage + ? theme.palette.error.dark + : "#475569", }} > - <ReactMarkdown>{message.content || "..."}</ReactMarkdown> + <div className={markdownStyles.markdown}> + <ReactMarkdown>{message.content || "..."}</ReactMarkdown> + </div> </Paper> </motion.div> ); diff --git a/src/components/chat/GlobalChatboxMarkdown.module.css b/src/components/chat/GlobalChatboxMarkdown.module.css new file mode 100644 index 0000000..f76ef6f --- /dev/null +++ b/src/components/chat/GlobalChatboxMarkdown.module.css @@ -0,0 +1,90 @@ +.markdown { + color: var(--chat-md-text); + font-size: 0.95rem; + line-height: 1.75; + word-break: break-word; +} + +.markdown p { + margin: 0; + white-space: pre-wrap; +} + +.markdown p + p { + margin-top: 0.75rem; +} + +.markdown h1, +.markdown h2, +.markdown h3, +.markdown h4, +.markdown h5, +.markdown h6 { + margin: 0.6rem 0; + line-height: 1.35; + font-weight: 700; + color: var(--chat-md-heading); +} + +.markdown h1 { font-size: 1.2rem; } +.markdown h2 { font-size: 1.12rem; } +.markdown h3 { font-size: 1.04rem; } + +.markdown a { + color: var(--chat-md-link); + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-all; +} + +.markdown a:hover { + color: var(--chat-md-link-hover); +} + +.markdown :not(pre) > code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + background: var(--chat-md-inline-code-bg); + border: 1px solid var(--chat-md-inline-code-border); + color: var(--chat-md-inline-code-text); + border-radius: 6px; + padding: 0.12rem 0.4rem; + font-size: 0.85em; +} + +.markdown pre { + background: var(--chat-md-pre-bg); + border: 1px solid var(--chat-md-pre-border); + color: var(--chat-md-pre-text); + border-radius: 10px; + padding: 0.75rem 0.9rem; + overflow-x: auto; + margin: 0.9rem 0; + font-size: 0.88em; +} + +.markdown pre code { + border: none; + background: transparent; + color: inherit; + padding: 0; +} + +.markdown ul, +.markdown ol { + padding-left: 1.4rem; + margin: 0.5rem 0; +} + +.markdown li { + margin: 0.3rem 0; + line-height: 1.65; +} + +.markdown blockquote { + margin: 0.8rem 0; + padding: 0.45rem 0.75rem; + border-left: 3px solid var(--chat-md-quote-border); + background: var(--chat-md-quote-bg); + color: var(--chat-md-quote-text); + border-radius: 6px; +} diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 244323f..7a6c09a 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -180,7 +180,7 @@ const BaseLayers: React.FC = () => { }; return ( - <div className="absolute right-17 bottom-11 z-1300"> + <div className="absolute right-17 bottom-11 z-20"> <div className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black" onMouseEnter={handleEnter} diff --git a/src/components/olmap/core/Controls/LayerControl.tsx b/src/components/olmap/core/Controls/LayerControl.tsx index c3dec4c..50372a3 100644 --- a/src/components/olmap/core/Controls/LayerControl.tsx +++ b/src/components/olmap/core/Controls/LayerControl.tsx @@ -134,7 +134,7 @@ const LayerControl: React.FC = () => { } return ( - <div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg z-1300 opacity-85 hover:opacity-100 transition-opacity max-w-xs"> + <div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg z-20 opacity-85 hover:opacity-100 transition-opacity max-w-xs"> <div className="ml-3 grid grid-cols-3"> {layerItems.map((item) => ( <FormControlLabel diff --git a/src/components/olmap/core/Controls/ScaleLine.tsx b/src/components/olmap/core/Controls/ScaleLine.tsx index 81c41a6..b93d9ed 100644 --- a/src/components/olmap/core/Controls/ScaleLine.tsx +++ b/src/components/olmap/core/Controls/ScaleLine.tsx @@ -67,7 +67,7 @@ const Scale: React.FC = () => { } `} </style> - <div className="absolute bottom-0 right-0 flex items-center gap-2 px-3 py-1.5 bg-white/90 hover:bg-white rounded-tl-xl shadow-lg backdrop-blur-sm text-xs font-medium text-slate-700 z-1300 transition-all duration-300 pointer-events-auto"> + <div className="absolute bottom-0 right-0 flex items-center gap-2 px-3 py-1.5 bg-white/90 hover:bg-white rounded-tl-xl shadow-lg backdrop-blur-sm text-xs font-medium text-slate-700 z-20 transition-all duration-300 pointer-events-auto"> <div ref={scaleLineRef} className="custom-scale-line flex items-center justify-center min-w-[60px]" diff --git a/src/components/olmap/core/Controls/Zoom.tsx b/src/components/olmap/core/Controls/Zoom.tsx index e857abb..8d96354 100644 --- a/src/components/olmap/core/Controls/Zoom.tsx +++ b/src/components/olmap/core/Controls/Zoom.tsx @@ -30,7 +30,7 @@ const Zoom: React.FC = () => { }; return ( - <div className="absolute right-4 bottom-11 z-1300"> + <div className="absolute right-4 bottom-11 z-20"> <div className="w-8 h-26 flex flex-col gap-2 items-center"> <div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black"> <button -- 2.54.0 From a101e797508ace97003087a3a379e937335a0875 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 27 Mar 2026 18:00:30 +0800 Subject: [PATCH 062/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=86=E6=B6=88=E6=81=AF=E8=A7=A3=E6=9E=90=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=9B=E4=BC=98=E5=8C=96=E8=AF=B7=E6=B1=82=E5=A4=B4=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=9B=E6=9B=B4=E6=96=B0=E9=83=A8=E5=88=86=20api=20?= =?UTF-8?q?base=20url?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 398 +++++++++++------- .../chat/chatMessageSections.test.ts | 43 ++ src/components/chat/chatMessageSections.ts | 55 +++ src/components/project/ProjectSelector.tsx | 1 + src/config/config.ts | 1 + src/contexts/ProjectContext.tsx | 4 +- src/lib/api.ts | 38 +- src/lib/apiFetch.ts | 20 +- src/lib/chatStream.test.ts | 9 + src/lib/chatStream.ts | 42 +- src/lib/requestHeaders.ts | 46 ++ 11 files changed, 464 insertions(+), 193 deletions(-) create mode 100644 src/components/chat/chatMessageSections.test.ts create mode 100644 src/components/chat/chatMessageSections.ts create mode 100644 src/lib/requestHeaders.ts diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 4261894..967e8aa 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo, useRef, useState, useEffect } from "react"; +import React, { useMemo, useRef, useState, useEffect, useCallback } from "react"; import ReactMarkdown from "react-markdown"; import { motion, AnimatePresence } from "framer-motion"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; @@ -11,6 +11,10 @@ import { Box, Drawer, IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, Paper, Stack, TextField, @@ -19,6 +23,7 @@ import { alpha, Tooltip, } from "@mui/material"; +import type { Theme } from "@mui/material/styles"; // Icons import CloseRounded from "@mui/icons-material/CloseRounded"; @@ -27,9 +32,11 @@ import StopRounded from "@mui/icons-material/StopRounded"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI import PersonRounded from "@mui/icons-material/PersonRounded"; import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; +import { parseAssistantMessageSections } from "./chatMessageSections"; // Types type Message = { @@ -47,6 +54,11 @@ type Props = { // Utils const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1"; +const THINK_TAG_ALIAS_PATTERN = /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; +const normalizeThoughtTagToken = (token: string): string => + token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) => + closingSlash ? "</think>" : "<think>", + ); type PersistedChatState = { messages: Message[]; @@ -136,6 +148,143 @@ const Blob = ({ color, size, top, left, delay }: { color: string; size: number; /> ); +type ChatMessageItemProps = { + message: Message; + theme: Theme; +}; + +const ChatMessageItem = React.memo( + ({ message, theme }: ChatMessageItemProps) => { + const isUser = message.role === "user"; + const isErrorMessage = Boolean(message.isError); + const parsedAssistantSections = + !isUser && !isErrorMessage + ? parseAssistantMessageSections(message.content) + : null; + const answerContent = parsedAssistantSections?.answer ?? message.content; + + return ( + <motion.div + initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ type: "spring", stiffness: 350, damping: 25 }} + style={{ + alignSelf: isUser ? "flex-end" : "flex-start", + maxWidth: "85%", + display: "flex", + flexDirection: isUser ? "row-reverse" : "row", + gap: 12, + alignItems: "flex-end", + }} + > + {!isUser && ( + <Avatar sx={{ width: 28, height: 28, bgcolor: isErrorMessage ? alpha(theme.palette.error.main, 0.12) : alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}> + {isErrorMessage ? ( + <ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} /> + ) : ( + <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> + )} + </Avatar> + )} + + <Paper + elevation={isUser ? 8 : isErrorMessage ? 1 : 2} + sx={{ + p: 2.5, + borderRadius: 4, + borderBottomRightRadius: isUser ? 4 : 24, + borderBottomLeftRadius: !isUser ? 4 : 24, + bgcolor: isUser ? "primary.main" : isErrorMessage ? alpha(theme.palette.error.light, 0.18) : "#fff", + color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary", + background: isUser + ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` + : isErrorMessage + ? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})` + : undefined, + border: isErrorMessage ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` : "none", + boxShadow: isUser + ? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}` + : isErrorMessage + ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` + : `0 4px 16px -4px ${alpha("#000", 0.05)}`, + "--chat-md-text": isUser + ? alpha("#fff", 0.96) + : isErrorMessage + ? theme.palette.error.dark + : "#1f2937", + "--chat-md-heading": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#111827", + "--chat-md-link": isUser + ? "#E3F2FD" + : isErrorMessage + ? theme.palette.error.main + : "#7C3AED", + "--chat-md-link-hover": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#6D28D9", + "--chat-md-inline-code-bg": isUser + ? "rgba(255,255,255,0.2)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#EEF2FF", + "--chat-md-inline-code-border": isUser + ? alpha("#fff", 0.16) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.25) + : "#CBD5E1", + "--chat-md-inline-code-text": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#334155", + "--chat-md-pre-bg": isUser + ? "rgba(11, 18, 32, 0.56)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#111827", + "--chat-md-pre-border": isUser + ? alpha("#fff", 0.12) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.3) + : "#64748B", + "--chat-md-pre-text": isUser + ? "#F8FAFC" + : isErrorMessage + ? theme.palette.error.dark + : "#E5E7EB", + "--chat-md-quote-border": isErrorMessage + ? alpha(theme.palette.error.main, 0.5) + : isUser + ? alpha("#fff", 0.5) + : "#7C3AED", + "--chat-md-quote-bg": isUser + ? alpha("#fff", 0.08) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.06) + : "#F5F3FF", + "--chat-md-quote-text": isUser + ? alpha("#fff", 0.9) + : isErrorMessage + ? theme.palette.error.dark + : "#475569", + }} + > + <div className={markdownStyles.markdown}> + <ReactMarkdown>{answerContent || "..."}</ReactMarkdown> + </div> + </Paper> + </motion.div> + ); + }, +); +ChatMessageItem.displayName = "ChatMessageItem"; + export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const initialChatStateRef = useRef<PersistedChatState | null>(null); if (initialChatStateRef.current === null) { @@ -148,12 +297,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [conversationId, setConversationId] = useState<string | undefined>( initialChatStateRef.current.conversationId ); + const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null); const abortRef = useRef<AbortController | null>(null); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); const theme = useTheme(); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); + const isHeaderMenuOpen = Boolean(headerMenuAnchorEl); // Auto-scroll useEffect(() => { @@ -204,10 +355,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onEvent: (event) => { if (event.type === "token") { if (!conversationId && event.conversationId) setConversationId(event.conversationId); + const normalizedToken = normalizeThoughtTagToken(event.content); setMessages((prev) => prev.map((m) => m.id === assistantId - ? { ...m, content: m.content + event.content, isError: false } + ? { ...m, content: m.content + normalizedToken, isError: false } : m ) ); @@ -256,6 +408,43 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { setIsStreaming(false); }; + const handleHeaderMenuOpen = useCallback( + (event: React.MouseEvent<HTMLElement>) => { + setHeaderMenuAnchorEl(event.currentTarget); + }, + [], + ); + + const handleHeaderMenuClose = useCallback(() => { + setHeaderMenuAnchorEl(null); + }, []); + + const handleNewConversation = useCallback(() => { + abortRef.current?.abort(); + setMessages([]); + setConversationId(undefined); + setInput(""); + setIsStreaming(false); + handleHeaderMenuClose(); + + window.setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }, [handleHeaderMenuClose]); + + const renderedMessages = useMemo( + () => + messages.map((message) => ( + <ChatMessageItem + key={message.id} + message={message} + theme={theme} + /> + )), + [messages, theme], + ); + + return ( <Drawer anchor="right" @@ -304,31 +493,43 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { whileHover={{ rotate: 10, scale: 1.1 }} whileTap={{ scale: 0.95 }} > - <Box sx={{ position: "relative" }}> + <IconButton + onClick={handleHeaderMenuOpen} + aria-label="打开聊天菜单" + aria-controls={isHeaderMenuOpen ? "global-chatbox-header-menu" : undefined} + aria-expanded={isHeaderMenuOpen ? "true" : undefined} + aria-haspopup="menu" + sx={{ + p: 0, + borderRadius: "50%", + }} + > + <Box sx={{ position: "relative" }}> <Avatar - sx={{ + sx={{ background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`, boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`, width: 48, height: 48, - }} + }} > - <AutoAwesome fontSize="medium" sx={{ color: "#fff" }} /> + <AutoAwesome fontSize="medium" sx={{ color: "#fff" }} /> </Avatar> - <Box - sx={{ - position: "absolute", - bottom: 2, - right: 2, - width: 12, - height: 12, - bgcolor: "success.main", - borderRadius: "50%", - border: "2px solid #fff", - boxShadow: "0 0 0 2px rgba(255,255,255,0.5)" - }} + <Box + sx={{ + position: "absolute", + bottom: 2, + right: 2, + width: 12, + height: 12, + bgcolor: "success.main", + borderRadius: "50%", + border: "2px solid #fff", + boxShadow: "0 0 0 2px rgba(255,255,255,0.5)" + }} /> - </Box> + </Box> + </IconButton> </motion.div> <Box> @@ -340,6 +541,41 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { </Typography> </Box> </Stack> + + <Menu + id="global-chatbox-header-menu" + anchorEl={headerMenuAnchorEl} + open={isHeaderMenuOpen} + onClose={handleHeaderMenuClose} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + slotProps={{ + paper: { + elevation: 8, + sx: { + mt: 1, + minWidth: 180, + borderRadius: 3, + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + backdropFilter: "blur(12px)", + bgcolor: alpha("#fff", 0.92), + boxShadow: `0 16px 40px -16px ${alpha(theme.palette.common.black, 0.28)}`, + }, + }, + }} + > + <MenuItem onClick={handleNewConversation}> + <ListItemIcon> + <AddCommentRounded fontSize="small" /> + </ListItemIcon> + <ListItemText + primary="新建对话" + secondary="清空当前会话" + primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 600 } }} + secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }} + /> + </MenuItem> + </Menu> <motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}> <IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}> @@ -387,129 +623,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { </motion.div> )} - {messages.map((message) => { - const isUser = message.role === "user"; - const isErrorMessage = Boolean(message.isError); - return ( - <motion.div - key={message.id} - initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - exit={{ opacity: 0, scale: 0.8 }} - transition={{ type: "spring", stiffness: 350, damping: 25 }} - style={{ - alignSelf: isUser ? "flex-end" : "flex-start", - maxWidth: "85%", - display: "flex", - flexDirection: isUser ? "row-reverse" : "row", - gap: 12, - alignItems: "flex-end", - }} - > - {!isUser && ( - <Avatar sx={{ width: 28, height: 28, bgcolor: isErrorMessage ? alpha(theme.palette.error.main, 0.12) : alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}> - {isErrorMessage ? ( - <ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} /> - ) : ( - <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> - )} - </Avatar> - )} - - <Paper - elevation={isUser ? 8 : isErrorMessage ? 1 : 2} - sx={{ - p: 2.5, - borderRadius: 4, - borderBottomRightRadius: isUser ? 4 : 24, - borderBottomLeftRadius: !isUser ? 4 : 24, - bgcolor: isUser ? "primary.main" : isErrorMessage ? alpha(theme.palette.error.light, 0.18) : "#fff", - color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary", - background: isUser - ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` - : isErrorMessage - ? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})` - : undefined, - border: isErrorMessage ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` : "none", - boxShadow: isUser - ? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}` - : isErrorMessage - ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` - : `0 4px 16px -4px ${alpha("#000", 0.05)}`, - "--chat-md-text": isUser - ? alpha("#fff", 0.96) - : isErrorMessage - ? theme.palette.error.dark - : "#1f2937", - "--chat-md-heading": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#111827", - "--chat-md-link": isUser - ? "#E3F2FD" - : isErrorMessage - ? theme.palette.error.main - : "#7C3AED", - "--chat-md-link-hover": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#6D28D9", - "--chat-md-inline-code-bg": isUser - ? "rgba(255,255,255,0.2)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#EEF2FF", - "--chat-md-inline-code-border": isUser - ? alpha("#fff", 0.16) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.25) - : "#CBD5E1", - "--chat-md-inline-code-text": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#334155", - "--chat-md-pre-bg": isUser - ? "rgba(11, 18, 32, 0.56)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#111827", - "--chat-md-pre-border": isUser - ? alpha("#fff", 0.12) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.3) - : "#64748B", - "--chat-md-pre-text": isUser - ? "#F8FAFC" - : isErrorMessage - ? theme.palette.error.dark - : "#E5E7EB", - "--chat-md-quote-border": isErrorMessage - ? alpha(theme.palette.error.main, 0.5) - : isUser - ? alpha("#fff", 0.5) - : "#7C3AED", - "--chat-md-quote-bg": isUser - ? alpha("#fff", 0.08) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.06) - : "#F5F3FF", - "--chat-md-quote-text": isUser - ? alpha("#fff", 0.9) - : isErrorMessage - ? theme.palette.error.dark - : "#475569", - }} - > - <div className={markdownStyles.markdown}> - <ReactMarkdown>{message.content || "..."}</ReactMarkdown> - </div> - </Paper> - </motion.div> - ); - })} + {renderedMessages} </AnimatePresence> {isStreaming && ( diff --git a/src/components/chat/chatMessageSections.test.ts b/src/components/chat/chatMessageSections.test.ts new file mode 100644 index 0000000..9034f32 --- /dev/null +++ b/src/components/chat/chatMessageSections.test.ts @@ -0,0 +1,43 @@ +import { parseAssistantMessageSections } from "./chatMessageSections"; + +describe("parseAssistantMessageSections", () => { + it("returns plain assistant content when there is no thought block", () => { + expect(parseAssistantMessageSections("直接回答")).toEqual({ + answer: "直接回答", + thought: null, + thoughtComplete: false, + }); + }); + + it("extracts a completed thought block and keeps the final answer visible", () => { + expect( + parseAssistantMessageSections("<think>先分析需求</think>\n\n最终回答"), + ).toEqual({ + answer: "最终回答", + thought: "先分析需求", + thoughtComplete: true, + }); + }); + + it("supports streaming thought content before the closing tag arrives", () => { + expect( + parseAssistantMessageSections("准备中...\n<think>继续推理中"), + ).toEqual({ + answer: "准备中...", + thought: "继续推理中", + thoughtComplete: false, + }); + }); + + it("merges multiple thought blocks into a single collapsed section", () => { + expect( + parseAssistantMessageSections( + "<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\n答案结尾", + ), + ).toEqual({ + answer: "答案开头\n\n答案结尾", + thought: "第一段思考\n\n第二段思考", + thoughtComplete: true, + }); + }); +}); diff --git a/src/components/chat/chatMessageSections.ts b/src/components/chat/chatMessageSections.ts new file mode 100644 index 0000000..f5bc7fe --- /dev/null +++ b/src/components/chat/chatMessageSections.ts @@ -0,0 +1,55 @@ +export type AssistantMessageSections = { + answer: string; + thought: string | null; + thoughtComplete: boolean; +}; + +const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi; +const THINK_OPEN_TAG = "<think>"; +const THINK_CLOSE_TAG = "</think>"; + +export const parseAssistantMessageSections = ( + content: string, +): AssistantMessageSections => { + if (!content) { + return { answer: "", thought: null, thoughtComplete: false }; + } + + const thoughtParts: string[] = []; + let answer = content; + + answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => { + const trimmedThought = thoughtContent.trim(); + if (trimmedThought) { + thoughtParts.push(trimmedThought); + } + + return "\n"; + }); + + const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG); + const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG); + const hasUnclosedThought = + lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex; + + if (hasUnclosedThought) { + const streamingThought = answer + .slice(lastOpenIndex + THINK_OPEN_TAG.length) + .trim(); + + if (streamingThought) { + thoughtParts.push(streamingThought); + } + + answer = answer.slice(0, lastOpenIndex); + } + + const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim(); + const normalizedThought = thoughtParts.join("\n\n").trim(); + + return { + answer: normalizedAnswer, + thought: normalizedThought || null, + thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought, + }; +}; diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx index 00d3b8a..6bfcf88 100644 --- a/src/components/project/ProjectSelector.tsx +++ b/src/components/project/ProjectSelector.tsx @@ -63,6 +63,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ try { const response = await apiFetch( `${config.BACKEND_URL}/api/v1/meta/projects`, + { projectHeaderMode: "omit" }, ); if (!response.ok) { throw new Error(`HTTP ${response.status}`); diff --git a/src/config/config.ts b/src/config/config.ts index 3aae064..ca689f2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,5 +1,6 @@ export const config = { BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", + COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787", MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater", MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 5f575ce..ce5660d 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -53,7 +53,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ try { // Open project backend (simulation model) const openResponse = await apiFetch( - `${config.BACKEND_URL}/openproject/?network=${net}`, + `${config.BACKEND_URL}/api/v1/openproject/?network=${net}`, { method: "POST", }, @@ -64,7 +64,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ // Fetch project metadata const infoResponse = await apiFetch( - `${config.BACKEND_URL}/project_info/?network=${net}`, + `${config.BACKEND_URL}/api/v1/project_info/?network=${net}`, ); if (!infoResponse.ok) { console.warn( diff --git a/src/lib/api.ts b/src/lib/api.ts index 7e0ccf0..88134f9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,9 +1,11 @@ -import axios from "axios"; +import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios"; import { config } from "@config/config"; -import { useProjectStore } from "@/store/projectStore"; -import { getAccessToken } from "@/lib/authToken"; import { signOut } from "next-auth/react"; import { useAuthStore } from "@/store/authStore"; +import { + applyAuthContextHeaders, + type AuthContextHeaderOptions, +} from "@/lib/requestHeaders"; export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL; @@ -13,26 +15,24 @@ export const api = axios.create({ let isSigningOut = false; -const isMetaProjectsRequest = (request: { +const resolveRequestUrl = (request: { baseURL?: string; url?: string; -}) => { - const url = `${request.baseURL ?? ""}${request.url ?? ""}`; - return url.includes("/api/v1/meta/projects"); -}; +}) => `${request.baseURL ?? ""}${request.url ?? ""}`; -api.interceptors.request.use(async (request) => { - const accessToken = await getAccessToken(); - if (accessToken) { - request.headers = request.headers ?? {}; - request.headers.Authorization = `Bearer ${accessToken}`; - } +export interface ApiRequestConfig + extends InternalAxiosRequestConfig, + AuthContextHeaderOptions {} - const projectId = useProjectStore.getState().currentProjectId; - if (projectId && !isMetaProjectsRequest(request)) { - request.headers = request.headers ?? {}; - request.headers["X-Project-Id"] = projectId; - } +api.interceptors.request.use(async (request: ApiRequestConfig) => { + const headers = new Headers( + request.headers + ? AxiosHeaders.from(request.headers).toJSON() as Record<string, string> + : undefined, + ); + await applyAuthContextHeaders(resolveRequestUrl(request), headers, request); + + request.headers = AxiosHeaders.from(Object.fromEntries(headers.entries())); return request; }); diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts index 7d8dd14..41d4197 100644 --- a/src/lib/apiFetch.ts +++ b/src/lib/apiFetch.ts @@ -1,7 +1,9 @@ -import { useProjectStore } from "@/store/projectStore"; -import { getAccessToken } from "@/lib/authToken"; import { signOut } from "next-auth/react"; import { useAuthStore } from "@/store/authStore"; +import { + applyAuthContextHeaders, + type AuthContextHeaderOptions, +} from "@/lib/requestHeaders"; let isSigningOut = false; @@ -12,10 +14,7 @@ const resolveUrl = (input: RequestInfo | URL) => { return ""; }; -const isMetaProjectsRequest = (input: RequestInfo | URL) => - resolveUrl(input).includes("/api/v1/meta/projects"); - -export interface ApiFetchInit extends RequestInit { +export interface ApiFetchInit extends RequestInit, AuthContextHeaderOptions { skipAuthRedirect?: boolean; } @@ -23,15 +22,8 @@ export const apiFetch = async ( input: RequestInfo | URL, init: ApiFetchInit = {}, ) => { - const projectId = useProjectStore.getState().currentProjectId; const headers = new Headers(init.headers ?? {}); - const accessToken = await getAccessToken(); - if (accessToken) { - headers.set("Authorization", `Bearer ${accessToken}`); - } - if (projectId && !isMetaProjectsRequest(input)) { - headers.set("X-Project-Id", projectId); - } + await applyAuthContextHeaders(resolveUrl(input), headers, init); const response = await fetch(input, { ...init, headers }); diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index d477ce3..c138d8a 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -54,6 +54,15 @@ describe("streamCopilotChat", () => { onEvent: (event) => events.push(event), }); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/copilot/chat/stream"), + expect.objectContaining({ + method: "POST", + projectHeaderMode: "include", + skipAuthRedirect: true, + }), + ); + expect(events).toEqual([ { type: "token", conversationId: "c1", content: "he" }, { type: "token", conversationId: "c1", content: "llo" }, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index b848f6d..9bc6d36 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -4,7 +4,12 @@ import { config } from "@config/config"; export type StreamEvent = | { type: "token"; conversationId: string; content: string } | { type: "done"; conversationId: string } - | { type: "error"; conversationId?: string; message: string; detail?: string }; + | { + type: "error"; + conversationId?: string; + message: string; + detail?: string; + }; type StreamOptions = { message: string; @@ -40,19 +45,23 @@ export const streamCopilotChat = async ({ }: StreamOptions) => { let response: Response; try { - response = await apiFetch(`${config.BACKEND_URL}/api/v1/copilot/chat/stream`, { - method: "POST", - signal, - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", + response = await apiFetch( + `${config.COPILOT_URL}/api/v1/copilot/chat/stream`, + { + method: "POST", + signal, + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ + message, + conversation_id: conversationId, + }), + projectHeaderMode: "include", + skipAuthRedirect: true, }, - body: JSON.stringify({ - message, - conversation_id: conversationId, - }), - skipAuthRedirect: true, - }); + ); } catch (error) { const detail = error instanceof Error ? error.message : String(error); onEvent({ @@ -66,17 +75,18 @@ export const streamCopilotChat = async ({ if (!response.ok || !response.body) { const detail = await response.text(); let message = "stream request failed"; - + if (response.status === 403) { message = "Permission denied. Please contact administrator."; } else if (response.status === 401) { message = "Login expired. Please sign in again."; } - + onEvent({ type: "error", message, - detail: (response.status === 403 || response.status === 401) ? undefined : detail, + detail: + response.status === 403 || response.status === 401 ? undefined : detail, }); return; } diff --git a/src/lib/requestHeaders.ts b/src/lib/requestHeaders.ts new file mode 100644 index 0000000..95e45ac --- /dev/null +++ b/src/lib/requestHeaders.ts @@ -0,0 +1,46 @@ +import { getAccessToken } from "@/lib/authToken"; +import { useProjectStore } from "@/store/projectStore"; + +export type AuthHeaderMode = "include" | "omit"; +export type ProjectHeaderMode = "auto" | "include" | "omit"; + +export interface AuthContextHeaderOptions { + authHeaderMode?: AuthHeaderMode; + projectHeaderMode?: ProjectHeaderMode; +} + +const shouldIncludeProjectHeader = ( + url: string, + projectHeaderMode: ProjectHeaderMode, +) => { + if (projectHeaderMode === "include") { + return true; + } + + if (projectHeaderMode === "omit") { + return false; + } + + return !url.includes("/api/v1/meta/projects"); +}; + +export const applyAuthContextHeaders = async ( + url: string, + headers: Headers, + options: AuthContextHeaderOptions = {}, +) => { + const accessToken = await getAccessToken(); + if (accessToken && options.authHeaderMode !== "omit") { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + const projectId = useProjectStore.getState().currentProjectId; + if ( + projectId && + shouldIncludeProjectHeader(url, options.projectHeaderMode ?? "auto") + ) { + headers.set("X-Project-Id", projectId); + } + + return headers; +}; -- 2.54.0 From 6559d0c062bac6d6d3d1641c88c76806dca29af3 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 30 Mar 2026 17:03:59 +0800 Subject: [PATCH 063/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Markdown=20?= =?UTF-8?q?=E6=8B=93=E5=B1=95=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 2089 ++++++++++++++--- package.json | 2 + src/components/chat/GlobalChatbox.tsx | 5 +- .../chat/GlobalChatboxMarkdown.module.css | 27 + 4 files changed, 1809 insertions(+), 314 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5421a0..4064837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,9 @@ "react-dom": "^19.1.0", "react-draggable": "^4.5.0", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-window": "^1.8.10", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.13", "zustand": "^5.0.11" }, @@ -6245,6 +6247,702 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@refinedev/mui/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@refinedev/mui/node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@refinedev/mui/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@refinedev/mui/node_modules/bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "license": "MIT" + }, + "node_modules/@refinedev/mui/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@refinedev/mui/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@refinedev/mui/node_modules/longest-streak": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", + "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-find-and-replace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-1.1.1.tgz", + "integrity": "sha512-9cKl33Y21lyckGzpSmEQnIDjEfeeWelN5s1kUW1LwdB0Fkuq2u+4GdqcGEygYxJE8GVqCl0741bYXHgamfWAZA==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-from-markdown": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", + "integrity": "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-string": "^2.0.0", + "micromark": "~2.11.0", + "parse-entities": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-gfm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-0.1.2.tgz", + "integrity": "sha512-NNkhDx/qYcuOWB7xHUGWZYVXvjPFFd6afg6/e2g+SV4r9q5XUcCbV4Wfa3DLYIiD+xAEZc6K4MGaE/m0KDcPwQ==", + "license": "MIT", + "dependencies": { + "mdast-util-gfm-autolink-literal": "^0.1.0", + "mdast-util-gfm-strikethrough": "^0.2.0", + "mdast-util-gfm-table": "^0.1.0", + "mdast-util-gfm-task-list-item": "^0.1.0", + "mdast-util-to-markdown": "^0.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-gfm-autolink-literal": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.3.tgz", + "integrity": "sha512-GjmLjWrXg1wqMIO9+ZsRik/s7PLwTaeCHVB7vRxUwLntZc8mzmTsLVr6HW1yLokcnhfURsn5zmSVdi3/xWWu1A==", + "license": "MIT", + "dependencies": { + "ccount": "^1.0.0", + "mdast-util-find-and-replace": "^1.1.0", + "micromark": "^2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-gfm-strikethrough": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.3.tgz", + "integrity": "sha512-5OQLXpt6qdbttcDG/UxYY7Yjj3e8P7X16LzvpX8pIQPYJ/C2Z1qFGMmcw+1PZMUM3Z8wt8NRfYTvCni93mgsgA==", + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "^0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-gfm-table": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.6.tgz", + "integrity": "sha512-j4yDxQ66AJSBwGkbpFEp9uG/LS1tZV3P33fN1gkyRB2LoRL+RR3f76m0HPHaby6F4Z5xr9Fv1URmATlRRUIpRQ==", + "license": "MIT", + "dependencies": { + "markdown-table": "^2.0.0", + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-gfm-task-list-item": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.6.tgz", + "integrity": "sha512-/d51FFIfPsSmCIRNp7E6pozM9z1GYPIkSy1urQ8s/o4TC22BZ7DqfHFWiqBD23bc7J3vV1Fc9O4QIHBlfuit8A==", + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-to-hast": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.2.0.tgz", + "integrity": "sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "mdast-util-definitions": "^4.0.0", + "mdurl": "^1.0.0", + "unist-builder": "^2.0.0", + "unist-util-generated": "^1.0.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-to-markdown": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz", + "integrity": "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "longest-streak": "^2.0.0", + "mdast-util-to-string": "^2.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.0.0", + "zwitch": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/micromark": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", + "integrity": "sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "parse-entities": "^2.0.0" + } + }, + "node_modules/@refinedev/mui/node_modules/micromark-extension-gfm": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.3.tgz", + "integrity": "sha512-oVN4zv5/tAIA+l3GbMi7lWeYpJ14oQyJ3uEim20ktYFAcfX1x3LNlFGGlmrZHt7u9YlKExmyJdDGaTt6cMSR/A==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0", + "micromark-extension-gfm-autolink-literal": "~0.5.0", + "micromark-extension-gfm-strikethrough": "~0.6.5", + "micromark-extension-gfm-table": "~0.4.0", + "micromark-extension-gfm-tagfilter": "~0.3.0", + "micromark-extension-gfm-task-list-item": "~0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/micromark-extension-gfm-autolink-literal": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.7.tgz", + "integrity": "sha512-ePiDGH0/lhcngCe8FtH4ARFoxKTUelMp4L7Gg2pujYD5CSMb9PbblnyL+AAMud/SNMyusbS2XDSiPIRcQoNFAw==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/micromark-extension-gfm-strikethrough": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.5.tgz", + "integrity": "sha512-PpOKlgokpQRwUesRwWEp+fHjGGkZEejj83k9gU5iXCbDG+XBA92BqnRKYJdfqfkrRcZRgGuPuXb7DaK/DmxOhw==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/micromark-extension-gfm-table": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.3.tgz", + "integrity": "sha512-hVGvESPq0fk6ALWtomcwmgLvH8ZSVpcPjzi0AjPclB9FsVRgMtGZkUcpE0zgjOCFAznKepF4z3hX8z6e3HODdA==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/micromark-extension-gfm-tagfilter": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-0.3.0.tgz", + "integrity": "sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/micromark-extension-gfm-task-list-item": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.3.tgz", + "integrity": "sha512-0zvM5iSLKrc/NQl84pZSjGo66aTGd57C1idmlWmE87lkMcXrTxg1uXa/nXomxJytoje9trP0NDLvw4bZ/Z/XCQ==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/react-markdown": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-6.0.3.tgz", + "integrity": "sha512-kQbpWiMoBHnj9myLlmZG9T1JdoT/OEyHK7hqM6CqFT14MAkgWiWBUYijLyBmxbntaN6dCDicPcUhWhci1QYodg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/unist": "^2.0.3", + "comma-separated-tokens": "^1.0.0", + "prop-types": "^15.7.2", + "property-information": "^5.3.0", + "react-is": "^17.0.0", + "remark-parse": "^9.0.0", + "remark-rehype": "^8.0.0", + "space-separated-tokens": "^1.1.0", + "style-to-object": "^0.3.0", + "unified": "^9.0.0", + "unist-util-visit": "^2.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@refinedev/mui/node_modules/react-markdown/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/@refinedev/mui/node_modules/remark-gfm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-1.0.0.tgz", + "integrity": "sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA==", + "license": "MIT", + "dependencies": { + "mdast-util-gfm": "^0.1.0", + "micromark-extension-gfm": "^0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/remark-parse": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz", + "integrity": "sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/remark-rehype": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-8.1.0.tgz", + "integrity": "sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA==", + "license": "MIT", + "dependencies": { + "mdast-util-to-hast": "^10.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/@refinedev/mui/node_modules/trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/mui/node_modules/unified": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", + "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "license": "MIT", + "dependencies": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/unist-util-position": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", + "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/mui/node_modules/zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/@refinedev/nextjs-router": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/@refinedev/nextjs-router/-/nextjs-router-7.0.4.tgz", @@ -9223,13 +9921,30 @@ "integrity": "sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -9243,12 +9958,12 @@ "license": "MIT" }, "node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", "dependencies": { - "@types/unist": "^2" + "@types/unist": "*" } }, "node_modules/@types/http-proxy": { @@ -9367,14 +10082,20 @@ "license": "MIT" }, "node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", "dependencies": { - "@types/unist": "^2" + "@types/unist": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", @@ -9468,9 +10189,9 @@ "license": "MIT" }, "node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, "node_modules/@types/yargs": { @@ -9763,7 +10484,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { @@ -10674,9 +11394,9 @@ } }, "node_modules/bail": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", - "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", "license": "MIT", "funding": { "type": "github", @@ -11068,9 +11788,9 @@ } }, "node_modules/ccount": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", - "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "license": "MIT", "funding": { "type": "github", @@ -11116,9 +11836,19 @@ } }, "node_modules/character-entities": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", - "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "license": "MIT", "funding": { "type": "github", @@ -11126,9 +11856,9 @@ } }, "node_modules/character-entities-legacy": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", - "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "license": "MIT", "funding": { "type": "github", @@ -11136,9 +11866,9 @@ } }, "node_modules/character-reference-invalid": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", - "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", "license": "MIT", "funding": { "type": "github", @@ -11386,9 +12116,9 @@ } }, "node_modules/comma-separated-tokens": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", - "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "license": "MIT", "funding": { "type": "github", @@ -12103,6 +12833,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -12231,7 +12974,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12266,6 +13008,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -13257,6 +14012,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -14490,6 +15255,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -14554,6 +15359,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -14871,9 +15686,9 @@ } }, "node_modules/inline-style-parser": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", - "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, "node_modules/inquirer": { @@ -14969,9 +15784,9 @@ } }, "node_modules/is-alphabetical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", - "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", "license": "MIT", "funding": { "type": "github", @@ -14979,13 +15794,13 @@ } }, "node_modules/is-alphanumerical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", - "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", "license": "MIT", "dependencies": { - "is-alphabetical": "^1.0.0", - "is-decimal": "^1.0.0" + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" }, "funding": { "type": "github", @@ -15162,9 +15977,9 @@ } }, "node_modules/is-decimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", - "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", "license": "MIT", "funding": { "type": "github", @@ -15262,9 +16077,9 @@ } }, "node_modules/is-hexadecimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", - "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", "license": "MIT", "funding": { "type": "github", @@ -15342,12 +16157,15 @@ } }, "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-plain-object": { @@ -17419,9 +18237,9 @@ } }, "node_modules/longest-streak": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", - "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", "license": "MIT", "funding": { "type": "github", @@ -17534,13 +18352,10 @@ "license": "AGPL-3.0" }, "node_modules/markdown-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", - "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "dependencies": { - "repeat-string": "^1.0.0" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -17635,13 +18450,29 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-find-and-replace": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-1.1.1.tgz", - "integrity": "sha512-9cKl33Y21lyckGzpSmEQnIDjEfeeWelN5s1kUW1LwdB0Fkuq2u+4GdqcGEygYxJE8GVqCl0741bYXHgamfWAZA==", + "node_modules/mdast-util-definitions/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", "license": "MIT", "dependencies": { - "escape-string-regexp": "^4.0.0", + "@types/unist": "^2.0.0", "unist-util-is": "^4.0.0", "unist-util-visit-parents": "^3.0.0" }, @@ -17650,17 +18481,66 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-from-markdown": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", - "integrity": "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==", + "node_modules/mdast-util-definitions/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-to-string": "^2.0.0", - "micromark": "~2.11.0", - "parse-entities": "^2.0.0", - "unist-util-stringify-position": "^2.0.0" + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { "type": "opencollective", @@ -17668,16 +18548,18 @@ } }, "node_modules/mdast-util-gfm": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-0.1.2.tgz", - "integrity": "sha512-NNkhDx/qYcuOWB7xHUGWZYVXvjPFFd6afg6/e2g+SV4r9q5XUcCbV4Wfa3DLYIiD+xAEZc6K4MGaE/m0KDcPwQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", "dependencies": { - "mdast-util-gfm-autolink-literal": "^0.1.0", - "mdast-util-gfm-strikethrough": "^0.2.0", - "mdast-util-gfm-table": "^0.1.0", - "mdast-util-gfm-task-list-item": "^0.1.0", - "mdast-util-to-markdown": "^0.6.1" + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17685,14 +18567,33 @@ } }, "node_modules/mdast-util-gfm-autolink-literal": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.3.tgz", - "integrity": "sha512-GjmLjWrXg1wqMIO9+ZsRik/s7PLwTaeCHVB7vRxUwLntZc8mzmTsLVr6HW1yLokcnhfURsn5zmSVdi3/xWWu1A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", "dependencies": { - "ccount": "^1.0.0", - "mdast-util-find-and-replace": "^1.1.0", - "micromark": "^2.11.3" + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17700,12 +18601,14 @@ } }, "node_modules/mdast-util-gfm-strikethrough": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.3.tgz", - "integrity": "sha512-5OQLXpt6qdbttcDG/UxYY7Yjj3e8P7X16LzvpX8pIQPYJ/C2Z1qFGMmcw+1PZMUM3Z8wt8NRfYTvCni93mgsgA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", "license": "MIT", "dependencies": { - "mdast-util-to-markdown": "^0.6.0" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17713,13 +18616,16 @@ } }, "node_modules/mdast-util-gfm-table": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.6.tgz", - "integrity": "sha512-j4yDxQ66AJSBwGkbpFEp9uG/LS1tZV3P33fN1gkyRB2LoRL+RR3f76m0HPHaby6F4Z5xr9Fv1URmATlRRUIpRQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", "license": "MIT", "dependencies": { - "markdown-table": "^2.0.0", - "mdast-util-to-markdown": "~0.6.0" + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17727,12 +18633,89 @@ } }, "node_modules/mdast-util-gfm-task-list-item": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.6.tgz", - "integrity": "sha512-/d51FFIfPsSmCIRNp7E6pozM9z1GYPIkSy1urQ8s/o4TC22BZ7DqfHFWiqBD23bc7J3vV1Fc9O4QIHBlfuit8A==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", "license": "MIT", "dependencies": { - "mdast-util-to-markdown": "~0.6.0" + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" }, "funding": { "type": "opencollective", @@ -17740,19 +18723,20 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.2.0.tgz", - "integrity": "sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "mdast-util-definitions": "^4.0.0", - "mdurl": "^1.0.0", - "unist-builder": "^2.0.0", - "unist-util-generated": "^1.0.0", - "unist-util-position": "^3.0.0", - "unist-util-visit": "^2.0.0" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", @@ -17760,17 +18744,20 @@ } }, "node_modules/mdast-util-to-markdown": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz", - "integrity": "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "longest-streak": "^2.0.0", - "mdast-util-to-string": "^2.0.0", - "parse-entities": "^2.0.0", - "repeat-string": "^1.0.0", - "zwitch": "^1.0.0" + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17778,10 +18765,13 @@ } }, "node_modules/mdast-util-to-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", - "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" @@ -17849,9 +18839,9 @@ } }, "node_modules/micromark": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", - "integrity": "sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", "funding": [ { "type": "GitHub Sponsors", @@ -17864,22 +18854,73 @@ ], "license": "MIT", "dependencies": { + "@types/debug": "^4.0.0", "debug": "^4.0.0", - "parse-entities": "^2.0.0" + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, "node_modules/micromark-extension-gfm": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.3.tgz", - "integrity": "sha512-oVN4zv5/tAIA+l3GbMi7lWeYpJ14oQyJ3uEim20ktYFAcfX1x3LNlFGGlmrZHt7u9YlKExmyJdDGaTt6cMSR/A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", "license": "MIT", "dependencies": { - "micromark": "~2.11.0", - "micromark-extension-gfm-autolink-literal": "~0.5.0", - "micromark-extension-gfm-strikethrough": "~0.6.5", - "micromark-extension-gfm-table": "~0.4.0", - "micromark-extension-gfm-tagfilter": "~0.3.0", - "micromark-extension-gfm-task-list-item": "~0.3.0" + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17887,12 +18928,35 @@ } }, "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.7.tgz", - "integrity": "sha512-ePiDGH0/lhcngCe8FtH4ARFoxKTUelMp4L7Gg2pujYD5CSMb9PbblnyL+AAMud/SNMyusbS2XDSiPIRcQoNFAw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", "license": "MIT", "dependencies": { - "micromark": "~2.11.3" + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17900,12 +18964,17 @@ } }, "node_modules/micromark-extension-gfm-strikethrough": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.5.tgz", - "integrity": "sha512-PpOKlgokpQRwUesRwWEp+fHjGGkZEejj83k9gU5iXCbDG+XBA92BqnRKYJdfqfkrRcZRgGuPuXb7DaK/DmxOhw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", "license": "MIT", "dependencies": { - "micromark": "~2.11.0" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17913,12 +18982,16 @@ } }, "node_modules/micromark-extension-gfm-table": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.3.tgz", - "integrity": "sha512-hVGvESPq0fk6ALWtomcwmgLvH8ZSVpcPjzi0AjPclB9FsVRgMtGZkUcpE0zgjOCFAznKepF4z3hX8z6e3HODdA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", "license": "MIT", "dependencies": { - "micromark": "~2.11.0" + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", @@ -17926,28 +18999,408 @@ } }, "node_modules/micromark-extension-gfm-tagfilter": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-0.3.0.tgz", - "integrity": "sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, "node_modules/micromark-extension-gfm-task-list-item": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.3.tgz", - "integrity": "sha512-0zvM5iSLKrc/NQl84pZSjGo66aTGd57C1idmlWmE87lkMcXrTxg1uXa/nXomxJytoje9trP0NDLvw4bZ/Z/XCQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", "license": "MIT", "dependencies": { - "micromark": "~2.11.0" + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -18911,23 +20364,30 @@ } }, "node_modules/parse-entities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", - "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", "license": "MIT", "dependencies": { - "character-entities": "^1.0.0", - "character-entities-legacy": "^1.0.0", - "character-reference-invalid": "^1.0.0", - "is-alphanumerical": "^1.0.0", - "is-decimal": "^1.0.0", - "is-hexadecimal": "^1.0.0" + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-headers": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", @@ -19471,13 +20931,10 @@ "license": "MIT" }, "node_modules/property-information": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", - "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -19718,40 +21175,32 @@ "license": "MIT" }, "node_modules/react-markdown": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-6.0.3.tgz", - "integrity": "sha512-kQbpWiMoBHnj9myLlmZG9T1JdoT/OEyHK7hqM6CqFT14MAkgWiWBUYijLyBmxbntaN6dCDicPcUhWhci1QYodg==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "@types/unist": "^2.0.3", - "comma-separated-tokens": "^1.0.0", - "prop-types": "^15.7.2", - "property-information": "^5.3.0", - "react-is": "^17.0.0", - "remark-parse": "^9.0.0", - "remark-rehype": "^8.0.0", - "space-separated-tokens": "^1.1.0", - "style-to-object": "^0.3.0", - "unified": "^9.0.0", - "unist-util-visit": "^2.0.0", - "vfile": "^4.0.0" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" }, "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" + "@types/react": ">=18", + "react": ">=18" } }, - "node_modules/react-markdown/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT" - }, "node_modules/react-reconciler": { "version": "0.29.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", @@ -19997,13 +21446,17 @@ } }, "node_modules/remark-gfm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-1.0.0.tgz", - "integrity": "sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", "license": "MIT", "dependencies": { - "mdast-util-gfm": "^0.1.0", - "micromark-extension-gfm": "^0.3.0" + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" }, "funding": { "type": "opencollective", @@ -20011,12 +21464,15 @@ } }, "node_modules/remark-parse": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz", - "integrity": "sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", "license": "MIT", "dependencies": { - "mdast-util-from-markdown": "^0.8.0" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" }, "funding": { "type": "opencollective", @@ -20024,12 +21480,31 @@ } }, "node_modules/remark-rehype": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-8.1.0.tgz", - "integrity": "sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", "license": "MIT", "dependencies": { - "mdast-util-to-hast": "^10.2.0" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" }, "funding": { "type": "opencollective", @@ -20960,9 +22435,9 @@ } }, "node_modules/space-separated-tokens": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", - "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "license": "MIT", "funding": { "type": "github", @@ -21273,6 +22748,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -21363,13 +22852,22 @@ ], "license": "MIT" }, - "node_modules/style-to-object": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", - "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", "license": "MIT", "dependencies": { - "inline-style-parser": "0.1.1" + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" } }, "node_modules/styled-jsx": { @@ -21785,10 +23283,20 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trough": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", - "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", "license": "MIT", "funding": { "type": "github", @@ -22175,46 +23683,24 @@ } }, "node_modules/unified": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", - "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", "dependencies": { - "bail": "^1.0.0", + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", "extend": "^3.0.0", - "is-buffer": "^2.0.0", - "is-plain-obj": "^2.0.0", - "trough": "^1.0.0", - "vfile": "^4.0.0" + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/unified/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/unist-builder": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz", @@ -22236,32 +23722,38 @@ } }, "node_modules/unist-util-is": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", - "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, "node_modules/unist-util-position": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", - "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, "node_modules/unist-util-stringify-position": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", - "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.2" + "@types/unist": "^3.0.0" }, "funding": { "type": "opencollective", @@ -22269,14 +23761,14 @@ } }, "node_modules/unist-util-visit": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", - "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^4.0.0", - "unist-util-visit-parents": "^3.0.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, "funding": { "type": "opencollective", @@ -22284,13 +23776,13 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", - "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^4.0.0" + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" }, "funding": { "type": "opencollective", @@ -22474,15 +23966,13 @@ } }, "node_modules/vfile": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", - "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "is-buffer": "^2.0.0", - "unist-util-stringify-position": "^2.0.0", - "vfile-message": "^2.0.0" + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" }, "funding": { "type": "opencollective", @@ -22490,42 +23980,19 @@ } }, "node_modules/vfile-message": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", - "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^2.0.0" + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/vfile/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -23050,9 +24517,9 @@ } }, "node_modules/zwitch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", - "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 8214974..4942854 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "react-dom": "^19.1.0", "react-draggable": "^4.5.0", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-window": "^1.8.10", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.13", "zustand": "^5.0.11" }, diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 967e8aa..817da9c 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useRef, useState, useEffect, useCallback } from "react"; import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { motion, AnimatePresence } from "framer-motion"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; @@ -21,7 +22,6 @@ import { Typography, useTheme, alpha, - Tooltip, } from "@mui/material"; import type { Theme } from "@mui/material/styles"; @@ -30,7 +30,6 @@ import CloseRounded from "@mui/icons-material/CloseRounded"; import SendRounded from "@mui/icons-material/SendRounded"; import StopRounded from "@mui/icons-material/StopRounded"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI -import PersonRounded from "@mui/icons-material/PersonRounded"; import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; @@ -276,7 +275,7 @@ const ChatMessageItem = React.memo( }} > <div className={markdownStyles.markdown}> - <ReactMarkdown>{answerContent || "..."}</ReactMarkdown> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{answerContent || "..."}</ReactMarkdown> </div> </Paper> </motion.div> diff --git a/src/components/chat/GlobalChatboxMarkdown.module.css b/src/components/chat/GlobalChatboxMarkdown.module.css index f76ef6f..3ffcc21 100644 --- a/src/components/chat/GlobalChatboxMarkdown.module.css +++ b/src/components/chat/GlobalChatboxMarkdown.module.css @@ -80,6 +80,33 @@ line-height: 1.65; } +.markdown table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-size: 0.88em; + border: 1px solid var(--chat-md-inline-code-border); + overflow: hidden; + border-radius: 8px; +} + +.markdown th, +.markdown td { + padding: 0.6rem 0.8rem; + border: 1px solid var(--chat-md-inline-code-border); + text-align: left; +} + +.markdown th { + background-color: var(--chat-md-inline-code-bg); + font-weight: 700; + color: var(--chat-md-heading); +} + +.markdown tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.02); +} + .markdown blockquote { margin: 0.8rem 0; padding: 0.45rem 0.75rem; -- 2.54.0 From adc12c13f9f5e08791c2996d07ea2f7e2255725e Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 30 Mar 2026 17:05:37 +0800 Subject: [PATCH 064/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=86=E5=8F=AF=E8=B0=83=E6=95=B4=E5=AE=BD=E5=BA=A6=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 64 ++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 817da9c..e1cbed4 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -293,6 +293,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); + const [width, setWidth] = useState(480); + const [isResizing, setIsResizing] = useState(false); const [conversationId, setConversationId] = useState<string | undefined>( initialChatStateRef.current.conversationId ); @@ -431,6 +433,35 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }, 0); }, [handleHeaderMenuClose]); + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }, []); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing) return; + const newWidth = window.innerWidth - e.clientX; + if (newWidth > 320 && newWidth < 1200) { + setWidth(newWidth); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + if (isResizing) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + } + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizing]); + const renderedMessages = useMemo( () => messages.map((message) => ( @@ -454,11 +485,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }} PaperProps={{ sx: { - width: { xs: "100%", sm: 480 }, + width: { xs: "100%", sm: width }, background: "transparent", boxShadow: "none", - overflow: "hidden", // Clip blobs + overflow: "visible", // Changed from "hidden" to show resizer handle if needed, though handle is inside. zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, + transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", // Disable transition during resize }, }} > @@ -472,6 +504,34 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { position: "relative", }} > + {/* Resize Handle */} + <Box + onMouseDown={handleMouseDown} + sx={{ + position: "absolute", + left: 0, + top: 0, + bottom: 0, + width: "6px", + cursor: "col-resize", + zIndex: 200, + "&:hover": { + bgcolor: alpha(theme.palette.primary.main, 0.2), + }, + "&::after": { + content: '""', + position: "absolute", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + width: "2px", + height: "40px", + bgcolor: alpha(theme.palette.divider, 0.4), + borderRadius: "1px", + } + }} + /> + {/* Ambient Blobs */} <Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} /> <Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} /> -- 2.54.0 From 295c959b521dc3f404a3b0e077c746396347bd0c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 2 Apr 2026 15:24:05 +0800 Subject: [PATCH 065/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=92=8C=E6=9C=97=E8=AF=BB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 314 +++++++++++++++++++++++++- 1 file changed, 311 insertions(+), 3 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index e1cbed4..eac479f 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -32,11 +32,40 @@ import StopRounded from "@mui/icons-material/StopRounded"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; +import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; +import PauseRounded from "@mui/icons-material/PauseRounded"; +import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; +import MicRounded from "@mui/icons-material/MicRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; import { parseAssistantMessageSections } from "./chatMessageSections"; +// WebKit Speech Recognition compatibility +interface SpeechRecognitionEvent extends Event { + readonly resultIndex: number; + readonly results: SpeechRecognitionResultList; +} + +interface SpeechRecognition extends EventTarget { + lang: string; + continuous: boolean; + interimResults: boolean; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onerror: ((event: Event) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; + abort(): void; +} + +declare global { + interface Window { + SpeechRecognition?: { new (): SpeechRecognition; prototype: SpeechRecognition }; + webkitSpeechRecognition?: { new (): SpeechRecognition; prototype: SpeechRecognition }; + } +} + // Types type Message = { id: string; @@ -59,6 +88,26 @@ const normalizeThoughtTagToken = (token: string): string => closingSlash ? "</think>" : "<think>", ); +type SpeechState = "idle" | "playing" | "paused"; + +const stripMarkdown = (md: string): string => + md + .replace(/```[\s\S]*?```/g, "") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[.*?\]\(.*?\)/g, "") + .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") + .replace(/#{1,6}\s+/g, "") + .replace(/\*\*\*(.+?)\*\*\*/g, "$1") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/>\s+/g, "") + .replace(/[-*+]\s+/g, "") + .replace(/\d+\.\s+/g, "") + .replace(/\n{2,}/g, "\n") + .replace(/<[^>]+>/g, "") + .trim(); + type PersistedChatState = { messages: Message[]; conversationId?: string; @@ -150,10 +199,16 @@ const Blob = ({ color, size, top, left, delay }: { color: string; size: number; type ChatMessageItemProps = { message: Message; theme: Theme; + messageSpeechState: SpeechState; + onSpeak: (messageId: string, text: string) => void; + onPause: () => void; + onResume: () => void; + onStopSpeech: () => void; + isTtsSupported: boolean; }; const ChatMessageItem = React.memo( - ({ message, theme }: ChatMessageItemProps) => { + ({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported }: ChatMessageItemProps) => { const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); const parsedAssistantSections = @@ -187,6 +242,7 @@ const ChatMessageItem = React.memo( </Avatar> )} + <Box> <Paper elevation={isUser ? 8 : isErrorMessage ? 1 : 2} sx={{ @@ -278,12 +334,194 @@ const ChatMessageItem = React.memo( <ReactMarkdown remarkPlugins={[remarkGfm]}>{answerContent || "..."}</ReactMarkdown> </div> </Paper> + {!isUser && !isErrorMessage && isTtsSupported && ( + <Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}> + {messageSpeechState === "idle" && ( + <IconButton + size="small" + onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} + aria-label="朗读消息" + sx={{ color: "text.secondary", opacity: 0.6, "&:hover": { opacity: 1 }, p: 0.5 }} + > + <VolumeUpRounded sx={{ fontSize: 16 }} /> + </IconButton> + )} + {messageSpeechState === "playing" && ( + <> + <IconButton + size="small" + onClick={onPause} + aria-label="暂停朗读" + sx={{ color: "primary.main", p: 0.5 }} + > + <PauseRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton + size="small" + onClick={onStopSpeech} + aria-label="停止朗读" + sx={{ color: "error.main", p: 0.5 }} + > + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + )} + {messageSpeechState === "paused" && ( + <> + <IconButton + size="small" + onClick={onResume} + aria-label="继续朗读" + sx={{ color: "primary.main", p: 0.5 }} + > + <PlayArrowRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton + size="small" + onClick={onStopSpeech} + aria-label="停止朗读" + sx={{ color: "error.main", p: 0.5 }} + > + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + )} + </Stack> + )} + </Box> </motion.div> ); }, ); ChatMessageItem.displayName = "ChatMessageItem"; +// --- Voice Hooks --- + +function useSpeechSynthesis() { + const [speechState, setSpeechState] = useState<SpeechState>("idle"); + const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null); + const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null); + + const isSupported = typeof window !== "undefined" && "speechSynthesis" in window; + + const stop = useCallback(() => { + if (!isSupported) return; + window.speechSynthesis.cancel(); + utteranceRef.current = null; + setSpeechState("idle"); + setSpeakingMessageId(null); + }, [isSupported]); + + const speak = useCallback( + (messageId: string, text: string) => { + if (!isSupported || !text) return; + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = "zh-CN"; + utterance.rate = 1; + utterance.onend = () => { + setSpeechState("idle"); + setSpeakingMessageId(null); + utteranceRef.current = null; + }; + utterance.onerror = () => { + setSpeechState("idle"); + setSpeakingMessageId(null); + utteranceRef.current = null; + }; + utterance.onpause = () => setSpeechState("paused"); + utterance.onresume = () => setSpeechState("playing"); + + utteranceRef.current = utterance; + setSpeakingMessageId(messageId); + setSpeechState("playing"); + window.speechSynthesis.speak(utterance); + }, + [isSupported], + ); + + const pause = useCallback(() => { + if (!isSupported) return; + window.speechSynthesis.pause(); + }, [isSupported]); + + const resume = useCallback(() => { + if (!isSupported) return; + window.speechSynthesis.resume(); + }, [isSupported]); + + useEffect(() => { + return () => { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + window.speechSynthesis.cancel(); + } + }; + }, []); + + return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported }; +} + +function useSpeechRecognition(onResult: (text: string) => void) { + const [isListening, setIsListening] = useState(false); + const recognitionRef = useRef<SpeechRecognition | null>(null); + const onResultRef = useRef(onResult); + useEffect(() => { + onResultRef.current = onResult; + }, [onResult]); + + const isSupported = + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window); + + const start = useCallback(() => { + if (!isSupported || recognitionRef.current) return; + const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition; + if (!Ctor) return; + + const recognition = new Ctor(); + recognition.lang = "zh-CN"; + recognition.continuous = true; + recognition.interimResults = false; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + for (let i = event.resultIndex; i < event.results.length; i++) { + if (event.results[i].isFinal) { + onResultRef.current(event.results[i][0].transcript); + } + } + }; + + recognition.onerror = () => { + setIsListening(false); + recognitionRef.current = null; + }; + + recognition.onend = () => { + setIsListening(false); + recognitionRef.current = null; + }; + + recognitionRef.current = recognition; + recognition.start(); + setIsListening(true); + }, [isSupported]); + + const stop = useCallback(() => { + recognitionRef.current?.stop(); + recognitionRef.current = null; + setIsListening(false); + }, []); + + useEffect(() => { + return () => { + recognitionRef.current?.stop(); + }; + }, []); + + return { isListening, start, stop, isSupported }; +} + export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const initialChatStateRef = useRef<PersistedChatState | null>(null); if (initialChatStateRef.current === null) { @@ -304,6 +542,28 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const inputRef = useRef<HTMLInputElement | null>(null); const theme = useTheme(); + // --- Voice Features --- + const { + speechState, + speakingMessageId, + speak: handleSpeak, + pause: handlePauseSpeech, + resume: handleResumeSpeech, + stop: handleStopSpeech, + isSupported: isTtsSupported, + } = useSpeechSynthesis(); + + const handleSpeechResult = useCallback((text: string) => { + setInput((prev) => prev + text); + }, []); + + const { + isListening, + start: startListening, + stop: stopListening, + isSupported: isSttSupported, + } = useSpeechRecognition(handleSpeechResult); + const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); const isHeaderMenuOpen = Boolean(headerMenuAnchorEl); @@ -333,6 +593,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleSend = async () => { const prompt = input.trim(); if (!prompt || isStreaming) return; + stopListening(); const userId = createId(); const assistantId = createId(); @@ -422,6 +683,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleNewConversation = useCallback(() => { abortRef.current?.abort(); + handleStopSpeech(); + stopListening(); setMessages([]); setConversationId(undefined); setInput(""); @@ -431,7 +694,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { window.setTimeout(() => { inputRef.current?.focus(); }, 0); - }, [handleHeaderMenuClose]); + }, [handleHeaderMenuClose, handleStopSpeech, stopListening]); const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -469,9 +732,15 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { key={message.id} message={message} theme={theme} + messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} + onSpeak={handleSpeak} + onPause={handlePauseSpeech} + onResume={handleResumeSpeech} + onStopSpeech={handleStopSpeech} + isTtsSupported={isTtsSupported} /> )), - [messages, theme], + [messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported], ); @@ -756,6 +1025,45 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }} /> + {isSttSupported && ( + <Box sx={{ display: "flex", alignItems: "center", mr: 1 }}> + {isListening ? ( + <motion.div + animate={{ scale: [1, 1.15, 1] }} + transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} + > + <IconButton + onClick={stopListening} + aria-label="停止语音输入" + sx={{ + color: "error.main", + bgcolor: alpha(theme.palette.error.main, 0.1), + width: 44, + height: 44, + "&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) }, + }} + > + <MicRounded /> + </IconButton> + </motion.div> + ) : ( + <IconButton + onClick={startListening} + disabled={isStreaming} + aria-label="语音输入" + sx={{ + color: "text.secondary", + width: 44, + height: 44, + "&:hover": { color: "primary.main" }, + }} + > + <MicRounded /> + </IconButton> + )} + </Box> + )} + <Box sx={{ pr: 0.5 }}> <AnimatePresence mode="wait"> {isStreaming ? ( -- 2.54.0 From a1c8041b11114e81ff17e9254e0f37612b085d87 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 2 Apr 2026 16:10:23 +0800 Subject: [PATCH 066/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B8=B8=E7=94=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 277 +++++++++++++++++++------- 1 file changed, 206 insertions(+), 71 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index eac479f..4f0f238 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -36,6 +36,8 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import MicRounded from "@mui/icons-material/MicRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; @@ -113,6 +115,12 @@ type PersistedChatState = { conversationId?: string; }; +const PRESET_PROMPTS = [ + "帮我分析当前管网压力异常点,并按风险等级排序。", + "基于当前状态,给出今天的巡检优先级和建议路线。", + "帮我生成一份今日运行简报,包含问题、原因和建议。", +]; + const getInitialChatState = (): PersistedChatState => { if (typeof window === "undefined") { return { messages: [], conversationId: undefined }; @@ -537,6 +545,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { initialChatStateRef.current.conversationId ); const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null); + const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false); const abortRef = useRef<AbortController | null>(null); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); @@ -590,79 +599,88 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { } }, [messages, conversationId]); + const sendPrompt = useCallback( + async (rawPrompt: string) => { + const prompt = rawPrompt.trim(); + if (!prompt || isStreaming) return; + stopListening(); + + const userId = createId(); + const assistantId = createId(); + setInput(""); + setIsStreaming(true); + + setMessages((prev) => [ + ...prev, + { id: userId, role: "user", content: prompt }, + { id: assistantId, role: "assistant", content: "" }, + ]); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + await streamCopilotChat({ + message: prompt, + conversationId, + signal: controller.signal, + onEvent: (event) => { + if (event.type === "token") { + if (!conversationId && event.conversationId) setConversationId(event.conversationId); + const normalizedToken = normalizeThoughtTagToken(event.content); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content + normalizedToken, isError: false } + : m + ) + ); + } else if (event.type === "done") { + if (!conversationId && event.conversationId) setConversationId(event.conversationId); + setIsStreaming(false); + } else if (event.type === "error") { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: m.content || `⚠️ **错误:** ${event.message}`, + isError: true, + } + : m + ) + ); + setIsStreaming(false); + } + }, + }); + } catch (error) { + if (abortRef.current?.signal.aborted) { + setMessages((prev) => + prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0)) + ); + return; + } + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true } + : m + ) + ); + setIsStreaming(false); + } finally { + abortRef.current = null; + setIsStreaming(false); + } + }, + [conversationId, isStreaming, stopListening], + ); + const handleSend = async () => { const prompt = input.trim(); if (!prompt || isStreaming) return; - stopListening(); - - const userId = createId(); - const assistantId = createId(); - setInput(""); - setIsStreaming(true); - - setMessages((prev) => [ - ...prev, - { id: userId, role: "user", content: prompt }, - { id: assistantId, role: "assistant", content: "" }, - ]); - - const controller = new AbortController(); - abortRef.current = controller; - - try { - await streamCopilotChat({ - message: prompt, - conversationId, - signal: controller.signal, - onEvent: (event) => { - if (event.type === "token") { - if (!conversationId && event.conversationId) setConversationId(event.conversationId); - const normalizedToken = normalizeThoughtTagToken(event.content); - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { ...m, content: m.content + normalizedToken, isError: false } - : m - ) - ); - } else if (event.type === "done") { - if (!conversationId && event.conversationId) setConversationId(event.conversationId); - setIsStreaming(false); - } else if (event.type === "error") { - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - content: m.content || `⚠️ **错误:** ${event.message}`, - isError: true, - } - : m - ) - ); - setIsStreaming(false); - } - }, - }); - } catch (error) { - if (abortRef.current?.signal.aborted) { - setMessages((prev) => - prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0)) - ); - return; - } - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true } - : m - ) - ); - setIsStreaming(false); - } finally { - abortRef.current = null; - setIsStreaming(false); - } + await sendPrompt(prompt); }; const handleAbort = () => { @@ -670,6 +688,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { setIsStreaming(false); }; + const handlePresetPromptSelect = useCallback((prompt: string) => { + setInput(prompt); + setIsPresetPanelOpen(false); + window.setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }, []); + const handleHeaderMenuOpen = useCallback( (event: React.MouseEvent<HTMLElement>) => { setHeaderMenuAnchorEl(event.currentTarget); @@ -913,7 +939,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { </Box> {/* Messages - Bouncy List */} - <Box sx={{ flex: 1, overflowY: "auto", px: 2.5, py: 2, display: "flex", flexDirection: "column", gap: 2.5, zIndex: 5 }}> + <Box + sx={{ + flex: 1, + overflowY: "auto", + px: 2.5, + py: 2, + display: "flex", + flexDirection: "column", + gap: 2.5, + zIndex: 5, + }} + > <AnimatePresence initial={false}> {messages.length === 0 && ( <motion.div @@ -980,6 +1017,104 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { {/* Input Area - Floating Capsule */} <Box sx={{ p: 3, zIndex: 10 }}> + <Box sx={{ mb: 1.25, display: "flex", justifyContent: "flex-end" }}> + <Box sx={{ position: "relative", width: "100%", maxWidth: 520, display: "flex", justifyContent: "flex-end" }}> + <AnimatePresence initial={false}> + {isPresetPanelOpen && ( + <motion.div + initial={{ opacity: 0, y: 8, scale: 0.98 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={{ opacity: 0, y: 8, scale: 0.98 }} + transition={{ type: "spring", stiffness: 320, damping: 26 }} + style={{ position: "absolute", right: 0, bottom: "calc(100% + 10px)", width: "100%", zIndex: 3 }} + > + <Paper + elevation={12} + sx={{ + p: 1.2, + borderRadius: 3, + bgcolor: alpha("#fff", 0.92), + backdropFilter: "blur(12px)", + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + boxShadow: `0 20px 48px -20px ${alpha(theme.palette.common.black, 0.3)}`, + }} + > + <Stack spacing={0.8}> + {PRESET_PROMPTS.map((prompt, index) => ( + <Box + key={`preset-${index}`} + component="button" + type="button" + onClick={() => handlePresetPromptSelect(prompt)} + sx={{ + textAlign: "left", + width: "100%", + px: 1.1, + py: 0.9, + borderRadius: 2, + border: `1px solid ${alpha(theme.palette.divider, 0.24)}`, + bgcolor: alpha("#fff", 0.72), + color: "text.secondary", + fontSize: "0.84rem", + lineHeight: 1.45, + cursor: "pointer", + transition: "all 0.18s ease", + "&:hover": { + borderColor: alpha(theme.palette.primary.main, 0.45), + color: "text.primary", + transform: "translateY(-1px)", + boxShadow: `0 8px 24px -16px ${alpha(theme.palette.primary.main, 0.6)}`, + }, + }} + > + {prompt} + </Box> + ))} + </Stack> + </Paper> + </motion.div> + )} + </AnimatePresence> + + <motion.div whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }}> + <Paper + elevation={10} + sx={{ + borderRadius: 99, + border: `1px solid ${alpha(theme.palette.divider, 0.1)}`, + bgcolor: alpha("#fff", 0.9), + backdropFilter: "blur(10px)", + boxShadow: `0 14px 40px -14px ${alpha(theme.palette.primary.main, 0.35)}`, + overflow: "hidden", + }} + > + <Stack direction="row" alignItems="center" spacing={1} sx={{ pl: 1.2, pr: 0.5, py: 0.5 }}> + <Avatar + sx={{ + width: 28, + height: 28, + background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`, + }} + > + <AutoAwesome sx={{ fontSize: 16, color: "#fff" }} /> + </Avatar> + <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.2 }}> + 常用功能 + </Typography> + <IconButton + size="small" + onClick={() => setIsPresetPanelOpen((prev) => !prev)} + aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"} + sx={{ color: "text.secondary" }} + > + {isPresetPanelOpen ? <KeyboardArrowDownRounded /> : <KeyboardArrowUpRounded />} + </IconButton> + </Stack> + </Paper> + </motion.div> + </Box> + </Box> + <motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} -- 2.54.0 From d610a09c140593836ffef3c6ce8ba3bc91fc7471 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 3 Apr 2026 11:49:05 +0800 Subject: [PATCH 067/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E8=A7=A3=E6=9E=90=E5=92=8C=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=93=8D=E4=BD=9C=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/ChatInlineChart.tsx | 178 ++++++++ src/components/chat/ChatToolCallBlock.tsx | 385 ++++++++++++++++++ src/components/chat/GlobalChatbox.tsx | 217 +++++++++- .../chat/chatMessageSections.test.ts | 90 +++- src/components/chat/chatMessageSections.ts | 111 +++++ src/components/olmap/SCADA/SCADADataPanel.tsx | 42 +- .../olmap/core/Controls/HistoryDataPanel.tsx | 42 +- .../olmap/core/Controls/Toolbar.tsx | 114 +++++- src/hooks/useChatToolActionHandler.ts | 41 ++ src/lib/chatStream.ts | 15 + src/store/chatToolStore.ts | 52 +++ 11 files changed, 1269 insertions(+), 18 deletions(-) create mode 100644 src/components/chat/ChatInlineChart.tsx create mode 100644 src/components/chat/ChatToolCallBlock.tsx create mode 100644 src/hooks/useChatToolActionHandler.ts create mode 100644 src/store/chatToolStore.ts diff --git a/src/components/chat/ChatInlineChart.tsx b/src/components/chat/ChatInlineChart.tsx new file mode 100644 index 0000000..1f7f9e4 --- /dev/null +++ b/src/components/chat/ChatInlineChart.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React, { useMemo } from "react"; +import ReactECharts from "echarts-for-react"; +import * as echarts from "echarts"; +import { Box, Paper, Typography, alpha, useTheme } from "@mui/material"; + +/* ------------------------------------------------------------------ */ +/* Inline chart rendered inside a chat message bubble. */ +/* Accepts structured data produced by the AI tool_call. */ +/* ------------------------------------------------------------------ */ + +export interface ChatChartSeries { + name: string; + data: number[]; + type?: "line" | "bar"; +} + +export interface ChatInlineChartProps { + title?: string; + chart_type?: "line" | "bar" | "pie"; + x_data?: string[]; + series?: ChatChartSeries[]; + y_axis_name?: string; + x_axis_name?: string; +} + +const COLORS = [ + "#5470c6", + "#91cc75", + "#fac858", + "#ee6666", + "#73c0de", + "#3ba272", + "#fc8452", + "#9a60b4", + "#ea7ccc", +]; + +export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ + title, + chart_type: chartType = "line", + x_data: xData, + series = [], + y_axis_name: yAxisName, + x_axis_name: xAxisName, +}) => { + const theme = useTheme(); + + const option = useMemo(() => { + if (!series.length) return null; + + /* ---------- Pie chart ---------- */ + if (chartType === "pie") { + const pieData = + series[0]?.data.map((value, i) => ({ + name: xData?.[i] ?? `${i}`, + value, + })) ?? []; + + return { + tooltip: { trigger: "item" }, + legend: { top: "bottom", textStyle: { fontSize: 11 } }, + series: [ + { + type: "pie", + radius: ["30%", "60%"], + data: pieData, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: "rgba(0, 0, 0, 0.5)", + }, + }, + label: { fontSize: 11 }, + }, + ], + color: COLORS, + }; + } + + /* ---------- Line / Bar chart ---------- */ + return { + tooltip: { trigger: "axis", confine: true }, + legend: { top: "top", textStyle: { fontSize: 11 } }, + grid: { + left: "5%", + right: "5%", + bottom: "12%", + top: title ? "18%" : "14%", + containLabel: true, + }, + xAxis: { + type: "category" as const, + boundaryGap: chartType === "bar", + data: xData ?? [], + axisLabel: { + fontSize: 10, + rotate: xData && xData.length > 10 ? 30 : 0, + }, + name: xAxisName, + }, + yAxis: { + type: "value" as const, + scale: true, + axisLabel: { fontSize: 10 }, + name: yAxisName, + }, + dataZoom: + xData && xData.length > 20 + ? [{ type: "inside", start: 0, end: 100 }] + : undefined, + series: series.map((s, i) => { + const color = COLORS[i % COLORS.length]; + return { + name: s.name, + type: (s.type ?? chartType) as string, + data: s.data, + symbol: chartType === "line" ? "none" : undefined, + smooth: chartType === "line", + itemStyle: { color }, + ...(chartType === "line" + ? { + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: alpha(color, 0.3) }, + { offset: 1, color: alpha(color, 0.05) }, + ]), + opacity: 0.3, + }, + } + : {}), + }; + }), + color: COLORS, + }; + }, [chartType, xData, series, title, yAxisName, xAxisName]); + + if (!option) { + return ( + <Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}> + 图表数据为空 + </Typography> + ); + } + + return ( + <Paper + elevation={0} + sx={{ + mt: 1.5, + mb: 1, + borderRadius: 3, + border: `1px solid ${alpha(theme.palette.divider, 0.15)}`, + bgcolor: alpha("#fff", 0.92), + overflow: "hidden", + }} + > + {title && ( + <Typography + variant="subtitle2" + sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }} + > + {title} + </Typography> + )} + <Box sx={{ px: 1, pb: 1 }}> + <ReactECharts + option={option} + style={{ height: 240, width: "100%" }} + notMerge + lazyUpdate + /> + </Box> + </Paper> + ); +}; diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx new file mode 100644 index 0000000..a3f4db7 --- /dev/null +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -0,0 +1,385 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { + Box, + Button, + Chip, + Paper, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import LocationOnRounded from "@mui/icons-material/LocationOnRounded"; +import TimelineRounded from "@mui/icons-material/TimelineRounded"; +import SensorsRounded from "@mui/icons-material/SensorsRounded"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; + +import { + useChatToolStore, + type ChatToolAction, +} from "@/store/chatToolStore"; +import type { ToolCall } from "./chatMessageSections"; + +/* ------------------------------------------------------------------ */ +/* Interactive card rendered inside a chat bubble for tool actions */ +/* (locate nodes/pipes, open history/SCADA panels). */ +/* ------------------------------------------------------------------ */ + +type ToolMeta = { + label: string; + icon: React.ReactNode; + actionLabel: string; + color: string; +}; + +const TOOL_META: Record<string, ToolMeta> = { + locate_nodes: { + label: "定位节点", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "定位到地图", + color: "#5470c6", + }, + locate_pipes: { + label: "定位管道", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "定位到地图", + color: "#91cc75", + }, + view_history: { + label: "查看计算结果", + icon: <TimelineRounded sx={{ fontSize: 18 }} />, + actionLabel: "查看曲线", + color: "#fac858", + }, + view_scada: { + label: "查看监测数据", + icon: <SensorsRounded sx={{ fontSize: 18 }} />, + actionLabel: "查看数据", + color: "#ee6666", + }, + show_chart: { + label: "显示图表", + icon: <TimelineRounded sx={{ fontSize: 18 }} />, + actionLabel: "显示", + color: "#73c0de", + }, +}; + +/* ---------- helpers ---------- */ + +function getToolDescription(toolCall: ToolCall): string { + const { params } = toolCall; + const resolveScadaFeatureInfos = (): [string, string][] => { + const rawFeatureInfos = params.feature_infos; + if (Array.isArray(rawFeatureInfos)) { + const normalizedFeatureInfos = rawFeatureInfos + .map((item) => (Array.isArray(item) ? item : null)) + .filter((item): item is [unknown, unknown] => Boolean(item)) + .map( + (item) => + [String(item[0] ?? ""), String(item[1] ?? "scada")] as [ + string, + string, + ], + ) + .filter(([id]) => id.trim().length > 0); + if (normalizedFeatureInfos.length > 0) { + return normalizedFeatureInfos; + } + } + + const rawDeviceIds = + params.device_ids ?? + params.deviceId ?? + params.device_id ?? + params.id ?? + params.ids; + const deviceIds = Array.isArray(rawDeviceIds) + ? rawDeviceIds.map((id) => String(id)) + : typeof rawDeviceIds === "string" + ? rawDeviceIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + : []; + + return deviceIds.map((id) => [id, "scada"]); + }; + const resolveTimeRange = () => ({ + startTime: + (params.start_time as string | undefined) ?? + (params.startTime as string | undefined) ?? + (params.from as string | undefined) ?? + (params.start as string | undefined), + endTime: + (params.end_time as string | undefined) ?? + (params.endTime as string | undefined) ?? + (params.to as string | undefined) ?? + (params.end as string | undefined), + }); + switch (toolCall.tool) { + case "locate_nodes": + case "locate_pipes": { + const ids = (params.ids as string[] | undefined) ?? []; + return ids.length > 3 + ? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个` + : ids.join(", "); + } + case "view_history": + case "view_scada": { + const infos = + toolCall.tool === "view_scada" + ? resolveScadaFeatureInfos() + : ((params.feature_infos as [string, string][] | undefined) ?? []); + const names = infos.map(([id]) => id); + const base = + names.length > 3 + ? `${names.slice(0, 3).join(", ")} 等 ${names.length} 个` + : names.join(", "); + const { startTime, endTime } = resolveTimeRange(); + if (!startTime && !endTime) { + return base; + } + const rangeLabel = `时间段: ${startTime ?? "--"} ~ ${endTime ?? "--"}`; + return base ? `${base} · ${rangeLabel}` : rangeLabel; + } + case "show_chart": { + return (params.title as string | undefined) ?? "数据图表"; + } + default: + return ""; + } +} + +function buildAction(toolCall: ToolCall): ChatToolAction | null { + const { params } = toolCall; + const resolveScadaFeatureInfos = (): [string, string][] => { + const rawFeatureInfos = params.feature_infos; + if (Array.isArray(rawFeatureInfos)) { + const normalizedFeatureInfos = rawFeatureInfos + .map((item) => (Array.isArray(item) ? item : null)) + .filter((item): item is [unknown, unknown] => Boolean(item)) + .map( + (item) => + [String(item[0] ?? ""), String(item[1] ?? "scada")] as [ + string, + string, + ], + ) + .filter(([id]) => id.trim().length > 0); + if (normalizedFeatureInfos.length > 0) { + return normalizedFeatureInfos; + } + } + + const rawDeviceIds = + params.device_ids ?? + params.deviceId ?? + params.device_id ?? + params.id ?? + params.ids; + const deviceIds = Array.isArray(rawDeviceIds) + ? rawDeviceIds.map((id) => String(id)) + : typeof rawDeviceIds === "string" + ? rawDeviceIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + : []; + + return deviceIds.map((id) => [id, "scada"]); + }; + const resolveTimeRange = () => ({ + startTime: + (params.start_time as string | undefined) ?? + (params.startTime as string | undefined) ?? + (params.from as string | undefined) ?? + (params.start as string | undefined), + endTime: + (params.end_time as string | undefined) ?? + (params.endTime as string | undefined) ?? + (params.to as string | undefined) ?? + (params.end as string | undefined), + }); + switch (toolCall.tool) { + case "locate_nodes": + return { + type: "locate_nodes", + ids: (params.ids as string[] | undefined) ?? [], + }; + case "locate_pipes": + return { + type: "locate_pipes", + ids: (params.ids as string[] | undefined) ?? [], + }; + case "view_history": { + const historyRange = resolveTimeRange(); + return { + type: "view_history", + featureInfos: + (params.feature_infos as [string, string][] | undefined) ?? [], + dataType: + (params.data_type as "realtime" | "scheme" | "none" | undefined) ?? + "realtime", + startTime: historyRange.startTime, + endTime: historyRange.endTime, + }; + } + case "view_scada": { + const scadaRange = resolveTimeRange(); + return { + type: "view_scada", + featureInfos: resolveScadaFeatureInfos(), + startTime: scadaRange.startTime, + endTime: scadaRange.endTime, + }; + } + case "show_chart": + return { + type: "show_chart", + title: params.title as string | undefined, + chartType: + (params.chart_type as "line" | "bar" | "pie" | undefined) ?? "line", + xData: (params.x_data as string[] | undefined) ?? [], + series: + (params.series as + | Array<{ name: string; data: number[]; type?: "line" | "bar" }> + | undefined) ?? [], + xAxisName: params.x_axis_name as string | undefined, + yAxisName: params.y_axis_name as string | undefined, + }; + default: + return null; + } +} + +/* ---------- component ---------- */ + +export interface ChatToolCallBlockProps { + toolCall: ToolCall; +} + +export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({ + toolCall, +}) => { + const theme = useTheme(); + const dispatch = useChatToolStore((s) => s.dispatch); + const [executed, setExecuted] = useState(false); + + const meta: ToolMeta = TOOL_META[toolCall.tool] ?? { + label: toolCall.tool, + icon: null, + actionLabel: "执行", + color: theme.palette.primary.main, + }; + + const description = getToolDescription(toolCall); + + const handleExecute = useCallback(() => { + const action = buildAction(toolCall); + if (action) { + dispatch(action); + setExecuted(true); + } + }, [toolCall, dispatch]); + + return ( + <Paper + elevation={0} + sx={{ + mt: 1.5, + mb: 1, + p: 1.5, + borderRadius: 3, + border: `1px solid ${alpha(meta.color, 0.25)}`, + bgcolor: alpha(meta.color, 0.04), + }} + > + <Stack direction="row" alignItems="center" spacing={1.5}> + {/* Icon */} + <Box + sx={{ + width: 32, + height: 32, + borderRadius: 2, + bgcolor: alpha(meta.color, 0.12), + display: "flex", + alignItems: "center", + justifyContent: "center", + color: meta.color, + flexShrink: 0, + }} + > + {meta.icon} + </Box> + + {/* Description */} + <Box sx={{ flex: 1, minWidth: 0 }}> + <Typography + variant="caption" + sx={{ + fontWeight: 600, + color: "text.primary", + display: "block", + }} + > + {meta.label} + </Typography> + {description && ( + <Typography + variant="caption" + sx={{ + color: "text.secondary", + fontSize: "0.75rem", + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {description} + </Typography> + )} + </Box> + + {/* Action */} + {executed ? ( + <Chip + icon={<CheckCircleRounded sx={{ fontSize: 16 }} />} + label="已执行" + size="small" + sx={{ + bgcolor: alpha("#4caf50", 0.1), + color: "#4caf50", + fontWeight: 600, + fontSize: "0.75rem", + }} + /> + ) : ( + <Button + size="small" + variant="outlined" + onClick={handleExecute} + sx={{ + borderColor: alpha(meta.color, 0.4), + color: meta.color, + fontWeight: 600, + fontSize: "0.75rem", + borderRadius: 2, + textTransform: "none", + whiteSpace: "nowrap", + "&:hover": { + borderColor: meta.color, + bgcolor: alpha(meta.color, 0.08), + }, + }} + > + {meta.actionLabel} + </Button> + )} + </Stack> + </Paper> + ); +}; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 4f0f238..84ffa38 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -41,7 +41,18 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; -import { parseAssistantMessageSections } from "./chatMessageSections"; +import type { StreamEvent } from "@/lib/chatStream"; +import { + parseAssistantMessageSections, + parseContentWithToolCalls, + type ContentSegment, +} from "./chatMessageSections"; +import { ChatInlineChart } from "./ChatInlineChart"; +import { ChatToolCallBlock } from "./ChatToolCallBlock"; +import { + useChatToolStore, + type ChatToolAction, +} from "@/store/chatToolStore"; // WebKit Speech Recognition compatibility interface SpeechRecognitionEvent extends Event { @@ -213,10 +224,11 @@ type ChatMessageItemProps = { onResume: () => void; onStopSpeech: () => void; isTtsSupported: boolean; + sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>; }; const ChatMessageItem = React.memo( - ({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported }: ChatMessageItemProps) => { + ({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, sseChartParams }: ChatMessageItemProps) => { const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); const parsedAssistantSections = @@ -225,6 +237,12 @@ const ChatMessageItem = React.memo( : null; const answerContent = parsedAssistantSections?.answer ?? message.content; + // Parse tool_call blocks from the answer for inline rendering + const contentSegments: ContentSegment[] = + !isUser && !isErrorMessage + ? parseContentWithToolCalls(answerContent).segments + : [{ type: "text", content: answerContent }]; + return ( <motion.div initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }} @@ -338,9 +356,89 @@ const ChatMessageItem = React.memo( : "#475569", }} > - <div className={markdownStyles.markdown}> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{answerContent || "..."}</ReactMarkdown> - </div> + {contentSegments.map((segment, segIdx) => { + if (segment.type === "text") { + const text = segment.content.trim(); + if (!text && contentSegments.length > 1) return null; + return ( + <div key={segIdx} className={markdownStyles.markdown}> + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {text || "..."} + </ReactMarkdown> + </div> + ); + } + if (segment.type === "tool_call") { + if (segment.toolCall.tool === "chart") { + return ( + <ChatInlineChart + key={segment.toolCall.id} + {...(segment.toolCall.params as Record<string, unknown>)} + /> + ); + } + if (segment.toolCall.tool === "show_chart") { + const p = segment.toolCall.params; + return ( + <ChatInlineChart + key={segment.toolCall.id} + title={(p.title as string) ?? undefined} + chart_type={(p.chart_type as "line" | "bar" | "pie") ?? "line"} + x_data={(p.x_data as string[]) ?? []} + series={(p.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []} + x_axis_name={(p.x_axis_name as string) ?? undefined} + y_axis_name={(p.y_axis_name as string) ?? undefined} + /> + ); + } + return ( + <ChatToolCallBlock + key={segment.toolCall.id} + toolCall={segment.toolCall} + /> + ); + } + if (segment.type === "tool_call_pending") { + return ( + <motion.div + key="tool-pending" + initial={{ opacity: 0 }} + animate={{ opacity: [0.4, 1, 0.4] }} + transition={{ + duration: 1.5, + repeat: Infinity, + ease: "easeInOut", + }} + style={{ + marginTop: 8, + display: "flex", + alignItems: "center", + gap: 8, + }} + > + <AutoAwesome + sx={{ fontSize: 14, color: "primary.main" }} + /> + <Typography variant="caption" color="text.secondary"> + 正在准备工具调用... + </Typography> + </motion.div> + ); + } + return null; + })} + {/* SSE-sourced inline charts (from show_chart tool_call events) */} + {sseChartParams?.map((chart, idx) => ( + <ChatInlineChart + key={`sse-chart-${idx}`} + title={(chart.params.title as string) ?? undefined} + chart_type={(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"} + x_data={(chart.params.x_data as string[]) ?? []} + series={(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []} + x_axis_name={(chart.params.x_axis_name as string) ?? undefined} + y_axis_name={(chart.params.y_axis_name as string) ?? undefined} + /> + ))} </Paper> {!isUser && !isErrorMessage && isTtsSupported && ( <Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}> @@ -546,6 +644,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { ); const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null); const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false); + + // SSE tool_call → inline chart data (keyed by assistantMessageId) + const [sseCharts, setSseCharts] = useState< + Record<string, Array<{ tool: string; params: Record<string, unknown> }>> + >({}); + + const dispatchToolAction = useChatToolStore((s) => s.dispatch); const abortRef = useRef<AbortController | null>(null); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); @@ -619,6 +724,101 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const controller = new AbortController(); abortRef.current = controller; + // Track SSE tool_call hashes to deduplicate against text-parsed tool_calls + const sseToolHashes = new Set<string>(); + + const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => { + const { tool, params } = event; + const hash = `${tool}:${JSON.stringify(params)}`; + sseToolHashes.add(hash); + const startTime = + (params.start_time as string | undefined) ?? + (params.startTime as string | undefined) ?? + (params.from as string | undefined) ?? + (params.start as string | undefined); + const endTime = + (params.end_time as string | undefined) ?? + (params.endTime as string | undefined) ?? + (params.to as string | undefined) ?? + (params.end as string | undefined); + const resolveScadaFeatureInfos = (): [string, string][] => { + const rawFeatureInfos = params.feature_infos; + if (Array.isArray(rawFeatureInfos)) { + const normalizedFeatureInfos = rawFeatureInfos + .map((item) => (Array.isArray(item) ? item : null)) + .filter((item): item is [unknown, unknown] => Boolean(item)) + .map( + (item) => + [String(item[0] ?? ""), String(item[1] ?? "scada")] as [ + string, + string, + ], + ) + .filter(([id]) => id.trim().length > 0); + if (normalizedFeatureInfos.length > 0) { + return normalizedFeatureInfos; + } + } + const rawDeviceIds = + params.device_ids ?? + params.deviceId ?? + params.device_id ?? + params.id ?? + params.ids; + const deviceIds = Array.isArray(rawDeviceIds) + ? rawDeviceIds.map((id) => String(id)) + : typeof rawDeviceIds === "string" + ? rawDeviceIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + : []; + return deviceIds.map((id) => [id, "scada"]); + }; + + // show_chart → store as inline chart for rendering + if (tool === "show_chart") { + setSseCharts((prev) => ({ + ...prev, + [assistantId]: [ + ...(prev[assistantId] ?? []), + { tool, params }, + ], + })); + return; + } + + // Other frontend tools → dispatch to chatToolStore immediately + const actionMap: Record<string, () => ChatToolAction | null> = { + locate_nodes: () => ({ + type: "locate_nodes" as const, + ids: (params.ids as string[]) ?? [], + }), + locate_pipes: () => ({ + type: "locate_pipes" as const, + ids: (params.ids as string[]) ?? [], + }), + view_history: () => ({ + type: "view_history" as const, + featureInfos: (params.feature_infos as [string, string][]) ?? [], + dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime", + startTime, + endTime, + }), + view_scada: () => ({ + type: "view_scada" as const, + featureInfos: resolveScadaFeatureInfos(), + startTime, + endTime, + }), + }; + const buildAction = actionMap[tool]; + if (buildAction) { + const action = buildAction(); + if (action) dispatchToolAction(action); + } + }; + try { await streamCopilotChat({ message: prompt, @@ -651,6 +851,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { ) ); setIsStreaming(false); + } else if (event.type === "tool_call") { + handleSseToolCall(event); } }, }); @@ -674,7 +876,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { setIsStreaming(false); } }, - [conversationId, isStreaming, stopListening], + [conversationId, isStreaming, stopListening, dispatchToolAction], ); const handleSend = async () => { @@ -764,9 +966,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onResume={handleResumeSpeech} onStopSpeech={handleStopSpeech} isTtsSupported={isTtsSupported} + sseChartParams={sseCharts[message.id]} /> )), - [messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported], + [messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts], ); diff --git a/src/components/chat/chatMessageSections.test.ts b/src/components/chat/chatMessageSections.test.ts index 9034f32..7e138dc 100644 --- a/src/components/chat/chatMessageSections.test.ts +++ b/src/components/chat/chatMessageSections.test.ts @@ -1,4 +1,7 @@ -import { parseAssistantMessageSections } from "./chatMessageSections"; +import { + parseAssistantMessageSections, + parseContentWithToolCalls, +} from "./chatMessageSections"; describe("parseAssistantMessageSections", () => { it("returns plain assistant content when there is no thought block", () => { @@ -41,3 +44,88 @@ describe("parseAssistantMessageSections", () => { }); }); }); + +describe("parseContentWithToolCalls", () => { + it("returns a single text segment when there are no tool calls", () => { + const result = parseContentWithToolCalls("普通文本回答"); + expect(result.segments).toEqual([ + { type: "text", content: "普通文本回答" }, + ]); + expect(result.toolCalls).toHaveLength(0); + }); + + it("parses a complete tool_call block", () => { + const content = + '分析完成。\n<tool_call>{"tool":"locate_nodes","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。'; + const result = parseContentWithToolCalls(content); + + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0].tool).toBe("locate_nodes"); + expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] }); + + expect(result.segments).toHaveLength(3); + expect(result.segments[0]).toEqual({ + type: "text", + content: "分析完成。", + }); + expect(result.segments[1]).toMatchObject({ + type: "tool_call", + toolCall: { tool: "locate_nodes" }, + }); + expect(result.segments[2]).toEqual({ + type: "text", + content: "以上是结果。", + }); + }); + + it("parses multiple tool_call blocks", () => { + const content = + '文本1\n<tool_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>'; + const result = parseContentWithToolCalls(content); + + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls[0].tool).toBe("locate_pipes"); + expect(result.toolCalls[1].tool).toBe("chart"); + expect(result.segments).toHaveLength(4); + }); + + it("detects an unclosed tool_call tag as pending (streaming)", () => { + const content = '正在分析...\n<tool_call>{"tool":"locate_no'; + const result = parseContentWithToolCalls(content); + + expect(result.segments).toHaveLength(2); + expect(result.segments[0]).toEqual({ + type: "text", + content: "正在分析...", + }); + expect(result.segments[1]).toEqual({ type: "tool_call_pending" }); + expect(result.toolCalls).toHaveLength(0); + }); + + it("strips partial opening tags during streaming", () => { + const content = "正在分析...\n<tool_c"; + const result = parseContentWithToolCalls(content); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0]).toEqual({ + type: "text", + content: "正在分析...", + }); + }); + + it("handles malformed JSON gracefully", () => { + const content = + '前文\n<tool_call>{invalid json}</tool_call>\n后文'; + const result = parseContentWithToolCalls(content); + + // Malformed tool call is treated as text + expect(result.toolCalls).toHaveLength(0); + expect(result.segments.length).toBeGreaterThanOrEqual(2); + }); + + it("returns empty segments for empty content", () => { + const result = parseContentWithToolCalls(""); + expect(result.segments).toHaveLength(0); + expect(result.toolCalls).toHaveLength(0); + }); +}); diff --git a/src/components/chat/chatMessageSections.ts b/src/components/chat/chatMessageSections.ts index f5bc7fe..89e349a 100644 --- a/src/components/chat/chatMessageSections.ts +++ b/src/components/chat/chatMessageSections.ts @@ -4,6 +4,30 @@ export type AssistantMessageSections = { thoughtComplete: boolean; }; +/* ------------------------------------------------------------------ */ +/* Tool-call types */ +/* ------------------------------------------------------------------ */ + +export type ToolCall = { + id: string; + tool: string; + params: Record<string, unknown>; +}; + +export type ContentSegment = + | { type: "text"; content: string } + | { type: "tool_call"; toolCall: ToolCall } + | { type: "tool_call_pending" }; + +export type ParsedToolContent = { + segments: ContentSegment[]; + toolCalls: ToolCall[]; +}; + +/* ------------------------------------------------------------------ */ +/* Think-block parsing */ +/* ------------------------------------------------------------------ */ + const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi; const THINK_OPEN_TAG = "<think>"; const THINK_CLOSE_TAG = "</think>"; @@ -53,3 +77,90 @@ export const parseAssistantMessageSections = ( thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought, }; }; + +/* ------------------------------------------------------------------ */ +/* Tool-call parsing */ +/* */ +/* AI responses may embed tool calls using: */ +/* <tool_call>{"tool":"locate_pipes","params":{...}}</tool_call> */ +/* */ +/* Returns ordered segments (text + tool_call interleaved) so the */ +/* UI can render them inline where the AI placed them. */ +/* ------------------------------------------------------------------ */ + +const TOOL_CALL_BLOCK_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/gi; +const TOOL_CALL_OPEN_TAG = "<tool_call>"; + +/** Regex to strip partial opening tag at the end of text during streaming. */ +const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/; + +export const parseContentWithToolCalls = ( + content: string, +): ParsedToolContent => { + if (!content) { + return { segments: [], toolCalls: [] }; + } + + const segments: ContentSegment[] = []; + const toolCalls: ToolCall[] = []; + let lastIndex = 0; + let tcIndex = 0; + + // Find all complete <tool_call>...</tool_call> blocks + const regex = /<tool_call>([\s\S]*?)<\/tool_call>/gi; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + // Text before this tool call + const textBefore = content.slice(lastIndex, match.index); + if (textBefore.trim()) { + segments.push({ type: "text", content: textBefore.trim() }); + } + + // Parse the tool call JSON + try { + const parsed = JSON.parse(match[1].trim()) as { + tool?: string; + params?: Record<string, unknown>; + }; + const toolCall: ToolCall = { + id: `tc-${tcIndex++}`, + tool: parsed.tool ?? "unknown", + params: parsed.params ?? {}, + }; + segments.push({ type: "tool_call", toolCall }); + toolCalls.push(toolCall); + } catch { + // Malformed JSON – treat as plain text + segments.push({ type: "text", content: match[0] }); + } + + lastIndex = match.index + match[0].length; + } + + // Handle remaining text after the last match + const remaining = content.slice(lastIndex); + + // Check for an unclosed <tool_call> tag (still streaming) + const unclosedIdx = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG); + if (unclosedIdx !== -1) { + const textBefore = remaining.slice(0, unclosedIdx); + if (textBefore.trim()) { + segments.push({ type: "text", content: textBefore.trim() }); + } + segments.push({ type: "tool_call_pending" }); + } else { + // Strip partial opening tags at the end (e.g. "<tool_c" while streaming) + const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim(); + if (cleaned) { + segments.push({ type: "text", content: cleaned }); + } + } + + // If nothing was parsed, return the original content as a single text segment + if (segments.length === 0) { + segments.push({ type: "text", content }); + } + + return { segments, toolCalls }; +}; diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index cf481f0..3b6544c 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -68,6 +68,10 @@ export interface SCADADataPanelProps { showCleaning?: boolean; /** 清洗数据的回调 */ onCleanData?: () => void; + /** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */ + start_time?: string; + /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ + end_time?: string; } type PanelTab = "chart" | "table"; @@ -314,6 +318,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ fractionDigits = 2, showCleaning = false, onCleanData, + start_time, + end_time, }) => { const { open } = useNotification(); const { data: user } = useGetIdentity<IUser>(); @@ -396,8 +402,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ }; }, [showCleaning]); - const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day")); - const [to, setTo] = useState<Dayjs>(() => dayjs()); + const [from, setFrom] = useState<Dayjs>(() => { + if (start_time) { + const parsedStart = dayjs(start_time); + if (parsedStart.isValid()) { + return parsedStart; + } + } + return dayjs().subtract(1, "day"); + }); + const [to, setTo] = useState<Dayjs>(() => { + if (end_time) { + const parsedEnd = dayjs(end_time); + if (parsedEnd.isValid()) { + return parsedEnd; + } + } + return dayjs(); + }); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]); const [loadingState, setLoadingState] = useState<LoadingState>("idle"); @@ -412,6 +434,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ setActiveTab(defaultTab); }, [defaultTab]); + useEffect(() => { + if (!start_time && !end_time) return; + if (start_time) { + const parsedStart = dayjs(start_time); + if (parsedStart.isValid()) { + setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart)); + } + } + if (end_time) { + const parsedEnd = dayjs(end_time); + if (parsedEnd.isValid()) { + setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd)); + } + } + }, [start_time, end_time]); + const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); const hasDevices = deviceIds.length > 0; diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index e8fb328..39b38b3 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -59,6 +59,10 @@ export interface SCADADataPanelProps { defaultTab?: "chart" | "table"; /** Y 轴数值的小数位数 */ fractionDigits?: number; + /** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */ + start_time?: string; + /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ + end_time?: string; } type PanelTab = "chart" | "table"; @@ -396,6 +400,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ scheme_name, defaultTab = "chart", fractionDigits = 2, + start_time, + end_time, }) => { // 从 featureInfos 中提取设备 ID 列表 const deviceIds = useMemo( @@ -403,8 +409,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ [featureInfos] ); - const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day")); - const [to, setTo] = useState<Dayjs>(() => dayjs()); + const [from, setFrom] = useState<Dayjs>(() => { + if (start_time) { + const parsedStart = dayjs(start_time); + if (parsedStart.isValid()) { + return parsedStart; + } + } + return dayjs().subtract(1, "day"); + }); + const [to, setTo] = useState<Dayjs>(() => { + if (end_time) { + const parsedEnd = dayjs(end_time); + if (parsedEnd.isValid()) { + return parsedEnd; + } + } + return dayjs(); + }); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]); const [loadingState, setLoadingState] = useState<LoadingState>("idle"); @@ -418,6 +440,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ setActiveTab(defaultTab); }, [defaultTab]); + useEffect(() => { + if (!start_time && !end_time) return; + if (start_time) { + const parsedStart = dayjs(start_time); + if (parsedStart.isValid()) { + setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart)); + } + } + if (end_time) { + const parsedEnd = dayjs(end_time); + if (parsedEnd.isValid()) { + setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd)); + } + } + }, [start_time, end_time]); + const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); const hasDevices = deviceIds.length > 0; diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 0c7345c..378da91 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -8,16 +8,20 @@ import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined"; import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件 import DrawPanel from "./DrawPanel"; // 引入绘图面板组件 import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件 +import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel"; import VectorSource from "ol/source/Vector"; import VectorLayer from "ol/layer/Vector"; import { Style, Stroke, Fill, Circle } from "ol/style"; import Feature from "ol/Feature"; +import { GeoJSON } from "ol/format"; +import { bbox, featureCollection } from "@turf/turf"; import StyleEditorPanel from "./StyleEditorPanel"; import { LayerStyleState } from "./StyleEditorPanel"; import StyleLegend from "./StyleLegend"; // 引入图例组件 -import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; +import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService"; import { useNotification } from "@refinedev/core"; +import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; @@ -51,6 +55,89 @@ const Toolbar: React.FC<ToolbarProps> = ({ const selectedDate = data?.selectedDate; const schemeName = data?.schemeName; + // Chat tool action → direct featureInfos override (bypasses OL Feature lookup) + const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState< + [string, string][] | null + >(null); + const [chatPanelType, setChatPanelType] = useState< + "realtime" | "scheme" | "none" + >("none"); + const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{ + startTime?: string; + endTime?: string; + } | null>(null); + + // Wire up chat tool actions (locate, view_history, view_scada) + useChatToolActionHandler( + useCallback( + (action) => { + const geojsonFormat = new GeoJSON(); + const zoomToFeatures = (features: Feature[]) => { + if (features.length === 0) return; + const geojsonFeatures = features.map((f) => + geojsonFormat.writeFeatureObject(f), + ); + const extent = bbox(featureCollection(geojsonFeatures as any)); + if (extent) { + map?.getView().fit(extent, { maxZoom: 18, duration: 1000 }); + } + }; + + switch (action.type) { + case "locate_nodes": { + queryFeaturesByIds(action.ids, "geo_junctions_mat").then( + (features) => { + if (features.length > 0) { + setHighlightFeatures(features); + zoomToFeatures(features); + } + }, + ); + break; + } + case "locate_pipes": { + queryFeaturesByIds(action.ids, "geo_pipes_mat").then( + (features) => { + if (features.length > 0) { + setHighlightFeatures(features); + zoomToFeatures(features); + } + }, + ); + break; + } + case "view_history": { + setChatPanelFeatureInfos(action.featureInfos); + setChatPanelType(action.dataType); + setChatPanelTimeRange({ + startTime: action.startTime, + endTime: action.endTime, + }); + setShowHistoryPanel(true); + break; + } + case "view_scada": { + setChatPanelFeatureInfos(action.featureInfos); + setChatPanelType("none"); + setChatPanelTimeRange({ + startTime: action.startTime, + endTime: action.endTime, + }); + setShowHistoryPanel(true); + setActiveTools((prev) => { + if (prev.includes("history")) { + return prev; + } + return [...prev, "history"]; + }); + break; + } + } + }, + [map], + ), + ); + // 样式状态管理 - 在 Toolbar 中管理,带有默认样式 const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([ { @@ -328,6 +415,8 @@ const Toolbar: React.FC<ToolbarProps> = ({ case "history": setShowHistoryPanel(false); setHighlightFeatures([]); + setChatPanelFeatureInfos(null); + setChatPanelTimeRange(null); break; } }; @@ -354,6 +443,8 @@ const Toolbar: React.FC<ToolbarProps> = ({ setHighlightFeatures([]); setShowDrawPanel(false); setShowHistoryPanel(false); + setChatPanelFeatureInfos(null); + setChatPanelTimeRange(null); // 样式编辑器保持其当前状态,不自动关闭 }; const [computedProperties, setComputedProperties] = useState< @@ -770,9 +861,16 @@ const Toolbar: React.FC<ToolbarProps> = ({ /> </div> {showHistoryPanel && - (HistoryPanel ? ( + (chatPanelType === "none" && chatPanelFeatureInfos ? ( + <SCADADataPanel + deviceIds={chatPanelFeatureInfos.map(([id]) => id)} + visible={showHistoryPanel} + start_time={chatPanelTimeRange?.startTime} + end_time={chatPanelTimeRange?.endTime} + /> + ) : HistoryPanel ? ( <HistoryPanel - featureInfos={(() => { + featureInfos={chatPanelFeatureInfos ?? (() => { if (highlightFeatures.length === 0 || !showHistoryPanel) return []; @@ -810,11 +908,13 @@ const Toolbar: React.FC<ToolbarProps> = ({ })()} scheme_type="burst_Analysis" scheme_name={schemeName} - type={queryType as "realtime" | "scheme" | "none"} + type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} + start_time={chatPanelTimeRange?.startTime} + end_time={chatPanelTimeRange?.endTime} /> ) : ( <HistoryDataPanel - featureInfos={(() => { + featureInfos={chatPanelFeatureInfos ?? (() => { if (highlightFeatures.length === 0 || !showHistoryPanel) return []; @@ -852,7 +952,9 @@ const Toolbar: React.FC<ToolbarProps> = ({ })()} scheme_type="burst_Analysis" scheme_name={schemeName} - type={queryType as "realtime" | "scheme" | "none"} + type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} + start_time={chatPanelTimeRange?.startTime} + end_time={chatPanelTimeRange?.endTime} /> ))} diff --git a/src/hooks/useChatToolActionHandler.ts b/src/hooks/useChatToolActionHandler.ts new file mode 100644 index 0000000..48516e9 --- /dev/null +++ b/src/hooks/useChatToolActionHandler.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; +import { + useChatToolStore, + type ChatToolAction, +} from "@/store/chatToolStore"; + +/** + * Subscribe to chat tool actions and invoke `handler` for each new action. + * + * Usage (inside a component with map access): + * ```ts + * useChatToolActionHandler((action) => { + * switch (action.type) { + * case "locate_nodes": handleLocateNodes(action.ids); break; + * case "locate_pipes": handleLocatePipes(action.ids); break; + * case "view_history": openHistoryPanel(action.featureInfos, action.dataType); break; + * case "view_scada": openScadaPanel(action.featureInfos); break; + * } + * }); + * ``` + */ +export function useChatToolActionHandler( + handler: (action: ChatToolAction) => void, +) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + const unsubscribe = useChatToolStore.subscribe( + (state, prevState) => { + if ( + state.actionSeq !== prevState.actionSeq && + state.lastAction + ) { + handlerRef.current(state.lastAction); + } + }, + ); + return unsubscribe; + }, []); +} diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 9bc6d36..3745809 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -9,6 +9,12 @@ export type StreamEvent = conversationId?: string; message: string; detail?: string; + } + | { + type: "tool_call"; + conversationId: string; + tool: string; + params: Record<string, unknown>; }; type StreamOptions = { @@ -113,6 +119,8 @@ export const streamCopilotChat = async ({ content?: string; message?: string; detail?: string; + tool?: string; + params?: Record<string, unknown>; }; if (event === "token") { onEvent({ @@ -132,6 +140,13 @@ export const streamCopilotChat = async ({ message: parsed.message ?? "unknown error", detail: parsed.detail, }); + } else if (event === "tool_call") { + onEvent({ + type: "tool_call", + conversationId: parsed.conversationId ?? "", + tool: parsed.tool ?? "", + params: parsed.params ?? {}, + }); } } catch { onEvent({ diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts new file mode 100644 index 0000000..0f37a0d --- /dev/null +++ b/src/store/chatToolStore.ts @@ -0,0 +1,52 @@ +import { create } from "zustand"; + +/* ------------------------------------------------------------------ */ +/* Chat Tool Action Store */ +/* Decouples chat tool calls from map/panel execution. */ +/* Chat dispatches actions → map/panel components subscribe & react. */ +/* ------------------------------------------------------------------ */ + +export type ChatToolAction = + | { type: "locate_nodes"; ids: string[] } + | { type: "locate_pipes"; ids: string[] } + | { + type: "view_history"; + featureInfos: [string, string][]; + dataType: "realtime" | "scheme" | "none"; + startTime?: string; + endTime?: string; + } + | { + type: "view_scada"; + featureInfos: [string, string][]; + startTime?: string; + endTime?: string; + } + | { + type: "show_chart"; + title?: string; + chartType?: "line" | "bar" | "pie"; + xData?: string[]; + series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>; + xAxisName?: string; + yAxisName?: string; + }; + +interface ChatToolState { + /** Most recent dispatched action (null until first dispatch). */ + lastAction: ChatToolAction | null; + /** Monotonically increasing counter – lets subscribers detect new actions. */ + actionSeq: number; + /** Dispatch a tool action from the chat. */ + dispatch: (action: ChatToolAction) => void; +} + +export const useChatToolStore = create<ChatToolState>((set) => ({ + lastAction: null, + actionSeq: 0, + dispatch: (action) => + set((state) => ({ + lastAction: action, + actionSeq: state.actionSeq + 1, + })), +})); -- 2.54.0 From c484aad1d3085beac562136c8aae0e7df28a0b72 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 3 Apr 2026 13:45:37 +0800 Subject: [PATCH 068/281] =?UTF-8?q?=E6=8A=BD=E8=B1=A1=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E6=96=B9=E6=B3=95=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E7=A7=8D=E5=9C=B0=E7=90=86=E8=A6=81=E7=B4=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/ChatToolCallBlock.tsx | 157 ++++++++++++++++-- src/components/chat/GlobalChatbox.tsx | 62 ++++++- .../chat/chatMessageSections.test.ts | 6 +- .../olmap/core/Controls/Toolbar.tsx | 59 ++++--- src/hooks/useChatToolActionHandler.ts | 8 +- src/store/chatToolStore.ts | 8 +- 6 files changed, 252 insertions(+), 48 deletions(-) diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index a3f4db7..75ae719 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -34,8 +34,26 @@ type ToolMeta = { color: string; }; +const LOCATE_TOOL_TO_LAYER: Record<string, string> = { + locate_features: "", + locate_junctions: "geo_junctions_mat", + locate_pipes: "geo_pipes_mat", + locate_valves: "geo_valves", + locate_reservoirs: "geo_reservoirs", + locate_pumps: "geo_pumps", + locate_tanks: "geo_tanks", +}; + +const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]); + const TOOL_META: Record<string, ToolMeta> = { - locate_nodes: { + locate_features: { + label: "定位要素", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "定位到地图", + color: "#5470c6", + }, + locate_junctions: { label: "定位节点", icon: <LocationOnRounded sx={{ fontSize: 18 }} />, actionLabel: "定位到地图", @@ -47,6 +65,30 @@ const TOOL_META: Record<string, ToolMeta> = { actionLabel: "定位到地图", color: "#91cc75", }, + locate_valves: { + label: "定位阀门", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "定位到地图", + color: "#9a60b4", + }, + locate_reservoirs: { + label: "定位水源", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "定位到地图", + color: "#ea7ccc", + }, + locate_pumps: { + label: "定位泵站", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "定位到地图", + color: "#fc8452", + }, + locate_tanks: { + label: "定位水池", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "定位到地图", + color: "#3ba272", + }, view_history: { label: "查看计算结果", icon: <TimelineRounded sx={{ fontSize: 18 }} />, @@ -71,6 +113,19 @@ const TOOL_META: Record<string, ToolMeta> = { function getToolDescription(toolCall: ToolCall): string { const { params } = toolCall; + const normalizeIds = (): string[] => { + const rawIds = params.ids; + if (Array.isArray(rawIds)) { + return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); + } + if (typeof rawIds === "string") { + return rawIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + } + return []; + }; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -119,13 +174,36 @@ function getToolDescription(toolCall: ToolCall): string { (params.to as string | undefined) ?? (params.end as string | undefined), }); + const resolveLocateFeatureType = (): string => { + const rawType = params.feature_type; + if (typeof rawType === "string" && rawType.trim()) { + return rawType.trim().toLowerCase(); + } + return ""; + }; switch (toolCall.tool) { - case "locate_nodes": - case "locate_pipes": { - const ids = (params.ids as string[] | undefined) ?? []; - return ids.length > 3 + case "locate_features": + case "locate_junctions": + case "locate_pipes": + case "locate_valves": + case "locate_reservoirs": + case "locate_pumps": + case "locate_tanks": { + const ids = normalizeIds(); + const idsText = + ids.length > 3 ? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个` : ids.join(", "); + if (toolCall.tool !== "locate_features") { + return idsText; + } + const featureType = resolveLocateFeatureType(); + if (!featureType) { + return idsText; + } + return idsText + ? `${featureType} · ${idsText}` + : featureType; } case "view_history": case "view_scada": { @@ -155,6 +233,19 @@ function getToolDescription(toolCall: ToolCall): string { function buildAction(toolCall: ToolCall): ChatToolAction | null { const { params } = toolCall; + const normalizeIds = (): string[] => { + const rawIds = params.ids; + if (Array.isArray(rawIds)) { + return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); + } + if (typeof rawIds === "string") { + return rawIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + } + return []; + }; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -204,16 +295,36 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { (params.end as string | undefined), }); switch (toolCall.tool) { - case "locate_nodes": + case "locate_features": { + const featureTypeRaw = params.feature_type; + const featureType = + typeof featureTypeRaw === "string" + ? featureTypeRaw.trim().toLowerCase() + : ""; + const config = locateFeatureTypeToConfig(featureType); + if (!config) return null; return { - type: "locate_nodes", - ids: (params.ids as string[] | undefined) ?? [], + type: "locate_features", + ids: normalizeIds(), + layer: config.layer, + geometryKind: config.geometryKind, }; + } + case "locate_junctions": case "locate_pipes": + case "locate_valves": + case "locate_reservoirs": + case "locate_pumps": + case "locate_tanks": { + const layer = LOCATE_TOOL_TO_LAYER[toolCall.tool]; + if (!layer) return null; return { - type: "locate_pipes", - ids: (params.ids as string[] | undefined) ?? [], + type: "locate_features", + ids: normalizeIds(), + layer, + geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point", }; + } case "view_history": { const historyRange = resolveTimeRange(); return { @@ -383,3 +494,29 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({ </Paper> ); }; + const locateFeatureTypeToConfig = ( + featureType: string, + ): { layer: string; geometryKind: "point" | "line" } | null => { + switch (featureType) { + case "junction": + case "junctions": + return { layer: "geo_junctions_mat", geometryKind: "point" }; + case "pipe": + case "pipes": + return { layer: "geo_pipes_mat", geometryKind: "line" }; + case "valve": + case "valves": + return { layer: "geo_valves", geometryKind: "point" }; + case "reservoir": + case "reservoirs": + return { layer: "geo_reservoirs", geometryKind: "point" }; + case "pump": + case "pumps": + return { layer: "geo_pumps", geometryKind: "point" }; + case "tank": + case "tanks": + return { layer: "geo_tanks", geometryKind: "point" }; + default: + return null; + } + }; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 84ffa38..9d1ee1b 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -789,15 +789,50 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { } // Other frontend tools → dispatch to chatToolStore immediately + const buildLocateFeaturesAction = ( + layer: string, + geometryKind: "point" | "line", + ): ChatToolAction => ({ + type: "locate_features" as const, + ids: (params.ids as string[]) ?? [], + layer, + geometryKind, + }); + const buildLocateByFeatureType = (): ChatToolAction | null => { + const rawType = params.feature_type; + const featureType = + typeof rawType === "string" ? rawType.trim().toLowerCase() : ""; + const featureTypeMap: Record< + string, + { layer: string; geometryKind: "point" | "line" } + > = { + junction: { layer: "geo_junctions_mat", geometryKind: "point" }, + junctions: { layer: "geo_junctions_mat", geometryKind: "point" }, + pipe: { layer: "geo_pipes_mat", geometryKind: "line" }, + pipes: { layer: "geo_pipes_mat", geometryKind: "line" }, + valve: { layer: "geo_valves", geometryKind: "point" }, + valves: { layer: "geo_valves", geometryKind: "point" }, + reservoir: { layer: "geo_reservoirs", geometryKind: "point" }, + reservoirs: { layer: "geo_reservoirs", geometryKind: "point" }, + pump: { layer: "geo_pumps", geometryKind: "point" }, + pumps: { layer: "geo_pumps", geometryKind: "point" }, + tank: { layer: "geo_tanks", geometryKind: "point" }, + tanks: { layer: "geo_tanks", geometryKind: "point" }, + }; + const config = featureTypeMap[featureType]; + if (!config) return null; + return buildLocateFeaturesAction(config.layer, config.geometryKind); + }; const actionMap: Record<string, () => ChatToolAction | null> = { - locate_nodes: () => ({ - type: "locate_nodes" as const, - ids: (params.ids as string[]) ?? [], - }), - locate_pipes: () => ({ - type: "locate_pipes" as const, - ids: (params.ids as string[]) ?? [], - }), + locate_features: buildLocateByFeatureType, + locate_pipes: () => buildLocateFeaturesAction("geo_pipes_mat", "line"), + locate_junctions: () => + buildLocateFeaturesAction("geo_junctions_mat", "point"), + locate_valves: () => buildLocateFeaturesAction("geo_valves", "point"), + locate_reservoirs: () => + buildLocateFeaturesAction("geo_reservoirs", "point"), + locate_pumps: () => buildLocateFeaturesAction("geo_pumps", "point"), + locate_tanks: () => buildLocateFeaturesAction("geo_tanks", "point"), view_history: () => ({ type: "view_history" as const, featureInfos: (params.feature_infos as [string, string][]) ?? [], @@ -837,6 +872,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { ); } else if (event.type === "done") { if (!conversationId && event.conversationId) setConversationId(event.conversationId); + setMessages((prev) => + prev.map((m) => + m.id === assistantId && m.content.trim().length === 0 + ? { + ...m, + content: "⚠️ **错误:** Copilot 未返回内容,请稍后重试。", + isError: true, + } + : m + ) + ); setIsStreaming(false); } else if (event.type === "error") { setMessages((prev) => diff --git a/src/components/chat/chatMessageSections.test.ts b/src/components/chat/chatMessageSections.test.ts index 7e138dc..bf73cc0 100644 --- a/src/components/chat/chatMessageSections.test.ts +++ b/src/components/chat/chatMessageSections.test.ts @@ -56,11 +56,11 @@ describe("parseContentWithToolCalls", () => { it("parses a complete tool_call block", () => { const content = - '分析完成。\n<tool_call>{"tool":"locate_nodes","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。'; + '分析完成。\n<tool_call>{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。'; const result = parseContentWithToolCalls(content); expect(result.toolCalls).toHaveLength(1); - expect(result.toolCalls[0].tool).toBe("locate_nodes"); + expect(result.toolCalls[0].tool).toBe("locate_junctions"); expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] }); expect(result.segments).toHaveLength(3); @@ -70,7 +70,7 @@ describe("parseContentWithToolCalls", () => { }); expect(result.segments[1]).toMatchObject({ type: "tool_call", - toolCall: { tool: "locate_nodes" }, + toolCall: { tool: "locate_junctions" }, }); expect(result.segments[2]).toEqual({ type: "text", diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 378da91..29946b5 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -15,6 +15,7 @@ import VectorLayer from "ol/layer/Vector"; import { Style, Stroke, Fill, Circle } from "ol/style"; import Feature from "ol/Feature"; import { GeoJSON } from "ol/format"; +import Point from "ol/geom/Point"; import { bbox, featureCollection } from "@turf/turf"; import StyleEditorPanel from "./StyleEditorPanel"; import { LayerStyleState } from "./StyleEditorPanel"; @@ -72,38 +73,52 @@ const Toolbar: React.FC<ToolbarProps> = ({ useCallback( (action) => { const geojsonFormat = new GeoJSON(); - const zoomToFeatures = (features: Feature[]) => { + const zoomToFeatures = ( + features: Feature[], + geometryKind: "point" | "line", + ) => { if (features.length === 0) return; + + if (geometryKind === "point" && features.length === 1) { + const geometry = features[0].getGeometry(); + if (geometry instanceof Point) { + map?.getView().animate({ + center: geometry.getCoordinates(), + zoom: 18, + duration: 1000, + }); + return; + } + } + const geojsonFeatures = features.map((f) => geojsonFormat.writeFeatureObject(f), ); const extent = bbox(featureCollection(geojsonFeatures as any)); if (extent) { - map?.getView().fit(extent, { maxZoom: 18, duration: 1000 }); + map?.getView().fit(extent, { + maxZoom: 18, + duration: 1000, + padding: geometryKind === "line" ? [60, 60, 60, 60] : [40, 40, 40, 40], + }); } }; + const locateFeatures = ( + ids: string[], + layer: string, + geometryKind: "point" | "line", + ) => { + queryFeaturesByIds(ids, layer).then((features) => { + if (features.length > 0) { + setHighlightFeatures(features); + zoomToFeatures(features, geometryKind); + } + }); + }; switch (action.type) { - case "locate_nodes": { - queryFeaturesByIds(action.ids, "geo_junctions_mat").then( - (features) => { - if (features.length > 0) { - setHighlightFeatures(features); - zoomToFeatures(features); - } - }, - ); - break; - } - case "locate_pipes": { - queryFeaturesByIds(action.ids, "geo_pipes_mat").then( - (features) => { - if (features.length > 0) { - setHighlightFeatures(features); - zoomToFeatures(features); - } - }, - ); + case "locate_features": { + locateFeatures(action.ids, action.layer, action.geometryKind); break; } case "view_history": { diff --git a/src/hooks/useChatToolActionHandler.ts b/src/hooks/useChatToolActionHandler.ts index 48516e9..a58e417 100644 --- a/src/hooks/useChatToolActionHandler.ts +++ b/src/hooks/useChatToolActionHandler.ts @@ -11,8 +11,7 @@ import { * ```ts * useChatToolActionHandler((action) => { * switch (action.type) { - * case "locate_nodes": handleLocateNodes(action.ids); break; - * case "locate_pipes": handleLocatePipes(action.ids); break; + * case "locate_features": handleLocateFeatures(action.ids, action.layer, action.geometryKind); break; * case "view_history": openHistoryPanel(action.featureInfos, action.dataType); break; * case "view_scada": openScadaPanel(action.featureInfos); break; * } @@ -23,7 +22,10 @@ export function useChatToolActionHandler( handler: (action: ChatToolAction) => void, ) { const handlerRef = useRef(handler); - handlerRef.current = handler; + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); useEffect(() => { const unsubscribe = useChatToolStore.subscribe( diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index 0f37a0d..3ad963a 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -7,8 +7,12 @@ import { create } from "zustand"; /* ------------------------------------------------------------------ */ export type ChatToolAction = - | { type: "locate_nodes"; ids: string[] } - | { type: "locate_pipes"; ids: string[] } + | { + type: "locate_features"; + ids: string[]; + layer: string; + geometryKind: "point" | "line"; + } | { type: "view_history"; featureInfos: [string, string][]; -- 2.54.0 From 56b4777dbd45c123d6500063600ce93ff08343c5 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 3 Apr 2026 13:58:44 +0800 Subject: [PATCH 069/281] =?UTF-8?q?=E4=BC=98=E5=8C=96queryFeaturesByIds=20?= =?UTF-8?q?ID=20=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E6=9F=A5=E8=AF=A2=E5=8A=9F=E8=83=BD=E6=AD=A3=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 17 ++++++++++++++++- src/utils/mapQueryService.ts | 9 +++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 9d1ee1b..c7e8d1f 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -789,12 +789,27 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { } // Other frontend tools → dispatch to chatToolStore immediately + const normalizeIds = (): string[] => { + const rawIds = params.ids; + if (Array.isArray(rawIds)) { + return rawIds + .map((id) => String(id).trim()) + .filter(Boolean); + } + if (typeof rawIds === "string") { + return rawIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + } + return []; + }; const buildLocateFeaturesAction = ( layer: string, geometryKind: "point" | "line", ): ChatToolAction => ({ type: "locate_features" as const, - ids: (params.ids as string[]) ?? [], + ids: normalizeIds(), layer, geometryKind, }); diff --git a/src/utils/mapQueryService.ts b/src/utils/mapQueryService.ts index 71db93b..7822b5e 100644 --- a/src/utils/mapQueryService.ts +++ b/src/utils/mapQueryService.ts @@ -224,11 +224,16 @@ const queryFeaturesByIds = async ( ids: string[], layer?: string ): Promise<Feature[]> => { - if (!ids.length) { + const normalizedIds = ids + .map((id) => String(id).trim()) + .filter((id) => id.length > 0); + + if (!normalizedIds.length) { return []; } - const orFilter = ids.map((id) => `id='${id}'`).join(" OR "); + const escapedIds = normalizedIds.map((id) => id.replace(/'/g, "''")); + const orFilter = escapedIds.map((id) => `id='${id}'`).join(" OR "); try { if (!layer) { -- 2.54.0 From d763876f86c3066c8f407f6e7f4d6fb04a7c3f86 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 3 Apr 2026 14:07:27 +0800 Subject: [PATCH 070/281] =?UTF-8?q?=E9=87=8D=E6=9E=84=20GlobalChatbox=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=8B=86=E5=88=86=E4=B8=BA=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.parts.tsx | 426 ++++++++++++++ src/components/chat/GlobalChatbox.tsx | 599 +------------------- src/components/chat/GlobalChatbox.types.ts | 18 + src/components/chat/GlobalChatbox.utils.ts | 54 ++ src/components/chat/GlobalChatbox.voice.ts | 158 ++++++ 5 files changed, 666 insertions(+), 589 deletions(-) create mode 100644 src/components/chat/GlobalChatbox.parts.tsx create mode 100644 src/components/chat/GlobalChatbox.types.ts create mode 100644 src/components/chat/GlobalChatbox.utils.ts create mode 100644 src/components/chat/GlobalChatbox.voice.ts diff --git a/src/components/chat/GlobalChatbox.parts.tsx b/src/components/chat/GlobalChatbox.parts.tsx new file mode 100644 index 0000000..3cd086b --- /dev/null +++ b/src/components/chat/GlobalChatbox.parts.tsx @@ -0,0 +1,426 @@ +"use client"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { motion } from "framer-motion"; +import { + Avatar, + Box, + IconButton, + Paper, + Stack, + Typography, + alpha, +} from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; +import PauseRounded from "@mui/icons-material/PauseRounded"; +import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; +import StopRounded from "@mui/icons-material/StopRounded"; +import { + parseAssistantMessageSections, + parseContentWithToolCalls, + type ContentSegment, +} from "./chatMessageSections"; +import { ChatInlineChart } from "./ChatInlineChart"; +import { ChatToolCallBlock } from "./ChatToolCallBlock"; +import markdownStyles from "./GlobalChatboxMarkdown.module.css"; +import type { Message, SpeechState } from "./GlobalChatbox.types"; +import { stripMarkdown } from "./GlobalChatbox.utils"; + +export const TypingIndicator = () => { + return ( + <Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}> + {[0, 1, 2].map((i) => ( + <motion.div + key={i} + initial={{ y: 0 }} + animate={{ y: [-4, 4, -4] }} + transition={{ + duration: 0.6, + repeat: Infinity, + delay: i * 0.15, + ease: "easeInOut", + }} + > + <Box + sx={{ + width: 8, + height: 8, + borderRadius: "50%", + background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)", + }} + /> + </motion.div> + ))} + </Stack> + ); +}; + +export const Blob = ({ + color, + size, + top, + left, + delay, +}: { + color: string; + size: number; + top: string; + left: string; + delay: number; +}) => ( + <motion.div + initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }} + animate={{ + scale: [0.8, 1.2, 0.8], + opacity: [0.3, 0.5, 0.3], + x: [0, 30, 0], + y: [0, -30, 0], + }} + transition={{ + duration: 8, + repeat: Infinity, + ease: "easeInOut", + delay, + }} + style={{ + position: "absolute", + top, + left, + width: size, + height: size, + borderRadius: "50%", + background: color, + filter: "blur(60px)", + zIndex: 0, + pointerEvents: "none", + }} + /> +); + +type ChatMessageItemProps = { + message: Message; + theme: Theme; + messageSpeechState: SpeechState; + onSpeak: (messageId: string, text: string) => void; + onPause: () => void; + onResume: () => void; + onStopSpeech: () => void; + isTtsSupported: boolean; + sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>; +}; + +export const ChatMessageItem = React.memo( + ({ + message, + theme, + messageSpeechState, + onSpeak, + onPause, + onResume, + onStopSpeech, + isTtsSupported, + sseChartParams, + }: ChatMessageItemProps) => { + const isUser = message.role === "user"; + const isErrorMessage = Boolean(message.isError); + const parsedAssistantSections = + !isUser && !isErrorMessage + ? parseAssistantMessageSections(message.content) + : null; + const answerContent = parsedAssistantSections?.answer ?? message.content; + + const contentSegments: ContentSegment[] = + !isUser && !isErrorMessage + ? parseContentWithToolCalls(answerContent).segments + : [{ type: "text", content: answerContent }]; + + return ( + <motion.div + initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ type: "spring", stiffness: 350, damping: 25 }} + style={{ + alignSelf: isUser ? "flex-end" : "flex-start", + maxWidth: "85%", + display: "flex", + flexDirection: isUser ? "row-reverse" : "row", + gap: 12, + alignItems: "flex-end", + }} + > + {!isUser && ( + <Avatar + sx={{ + width: 28, + height: 28, + bgcolor: isErrorMessage + ? alpha(theme.palette.error.main, 0.12) + : alpha(theme.palette.secondary.main, 0.1), + mb: 0.5, + }} + > + {isErrorMessage ? ( + <ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} /> + ) : ( + <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> + )} + </Avatar> + )} + + <Box> + <Paper + elevation={isUser ? 8 : isErrorMessage ? 1 : 2} + sx={{ + p: 2.5, + borderRadius: 4, + borderBottomRightRadius: isUser ? 4 : 24, + borderBottomLeftRadius: !isUser ? 4 : 24, + bgcolor: isUser + ? "primary.main" + : isErrorMessage + ? alpha(theme.palette.error.light, 0.18) + : "#fff", + color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary", + background: isUser + ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` + : isErrorMessage + ? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})` + : undefined, + border: isErrorMessage + ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` + : "none", + boxShadow: isUser + ? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}` + : isErrorMessage + ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` + : `0 4px 16px -4px ${alpha("#000", 0.05)}`, + "--chat-md-text": isUser + ? alpha("#fff", 0.96) + : isErrorMessage + ? theme.palette.error.dark + : "#1f2937", + "--chat-md-heading": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#111827", + "--chat-md-link": isUser + ? "#E3F2FD" + : isErrorMessage + ? theme.palette.error.main + : "#7C3AED", + "--chat-md-link-hover": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#6D28D9", + "--chat-md-inline-code-bg": isUser + ? "rgba(255,255,255,0.2)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#EEF2FF", + "--chat-md-inline-code-border": isUser + ? alpha("#fff", 0.16) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.25) + : "#CBD5E1", + "--chat-md-inline-code-text": isUser + ? "#fff" + : isErrorMessage + ? theme.palette.error.dark + : "#334155", + "--chat-md-pre-bg": isUser + ? "rgba(11, 18, 32, 0.56)" + : isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#111827", + "--chat-md-pre-border": isUser + ? alpha("#fff", 0.12) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.3) + : "#64748B", + "--chat-md-pre-text": isUser + ? "#F8FAFC" + : isErrorMessage + ? theme.palette.error.dark + : "#E5E7EB", + "--chat-md-quote-border": isErrorMessage + ? alpha(theme.palette.error.main, 0.5) + : isUser + ? alpha("#fff", 0.5) + : "#7C3AED", + "--chat-md-quote-bg": isUser + ? alpha("#fff", 0.08) + : isErrorMessage + ? alpha(theme.palette.error.main, 0.06) + : "#F5F3FF", + "--chat-md-quote-text": isUser + ? alpha("#fff", 0.9) + : isErrorMessage + ? theme.palette.error.dark + : "#475569", + }} + > + {contentSegments.map((segment, segIdx) => { + if (segment.type === "text") { + const text = segment.content.trim(); + if (!text && contentSegments.length > 1) return null; + return ( + <div key={segIdx} className={markdownStyles.markdown}> + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {text || "..."} + </ReactMarkdown> + </div> + ); + } + if (segment.type === "tool_call") { + if (segment.toolCall.tool === "chart") { + return ( + <ChatInlineChart + key={segment.toolCall.id} + {...(segment.toolCall.params as Record<string, unknown>)} + /> + ); + } + if (segment.toolCall.tool === "show_chart") { + const p = segment.toolCall.params; + return ( + <ChatInlineChart + key={segment.toolCall.id} + title={(p.title as string) ?? undefined} + chart_type={ + (p.chart_type as "line" | "bar" | "pie") ?? "line" + } + x_data={(p.x_data as string[]) ?? []} + series={ + (p.series as import("./ChatInlineChart").ChatChartSeries[]) ?? + [] + } + x_axis_name={(p.x_axis_name as string) ?? undefined} + y_axis_name={(p.y_axis_name as string) ?? undefined} + /> + ); + } + return ( + <ChatToolCallBlock + key={segment.toolCall.id} + toolCall={segment.toolCall} + /> + ); + } + if (segment.type === "tool_call_pending") { + return ( + <motion.div + key="tool-pending" + initial={{ opacity: 0 }} + animate={{ opacity: [0.4, 1, 0.4] }} + transition={{ + duration: 1.5, + repeat: Infinity, + ease: "easeInOut", + }} + style={{ + marginTop: 8, + display: "flex", + alignItems: "center", + gap: 8, + }} + > + <AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} /> + <Typography variant="caption" color="text.secondary"> + 正在准备工具调用... + </Typography> + </motion.div> + ); + } + return null; + })} + {sseChartParams?.map((chart, idx) => ( + <ChatInlineChart + key={`sse-chart-${idx}`} + title={(chart.params.title as string) ?? undefined} + chart_type={ + (chart.params.chart_type as "line" | "bar" | "pie") ?? "line" + } + x_data={(chart.params.x_data as string[]) ?? []} + series={ + (chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ?? + [] + } + x_axis_name={(chart.params.x_axis_name as string) ?? undefined} + y_axis_name={(chart.params.y_axis_name as string) ?? undefined} + /> + ))} + </Paper> + {!isUser && !isErrorMessage && isTtsSupported && ( + <Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}> + {messageSpeechState === "idle" && ( + <IconButton + size="small" + onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} + aria-label="朗读消息" + sx={{ + color: "text.secondary", + opacity: 0.6, + "&:hover": { opacity: 1 }, + p: 0.5, + }} + > + <VolumeUpRounded sx={{ fontSize: 16 }} /> + </IconButton> + )} + {messageSpeechState === "playing" && ( + <> + <IconButton + size="small" + onClick={onPause} + aria-label="暂停朗读" + sx={{ color: "primary.main", p: 0.5 }} + > + <PauseRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton + size="small" + onClick={onStopSpeech} + aria-label="停止朗读" + sx={{ color: "error.main", p: 0.5 }} + > + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + )} + {messageSpeechState === "paused" && ( + <> + <IconButton + size="small" + onClick={onResume} + aria-label="继续朗读" + sx={{ color: "primary.main", p: 0.5 }} + > + <PlayArrowRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton + size="small" + onClick={onStopSpeech} + aria-label="停止朗读" + sx={{ color: "error.main", p: 0.5 }} + > + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + )} + </Stack> + )} + </Box> + </motion.div> + ); + }, +); + +ChatMessageItem.displayName = "ChatMessageItem"; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index c7e8d1f..90854e8 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -1,10 +1,7 @@ "use client"; import React, { useMemo, useRef, useState, useEffect, useCallback } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { motion, AnimatePresence } from "framer-motion"; -import markdownStyles from "./GlobalChatboxMarkdown.module.css"; // MUI import { @@ -23,18 +20,13 @@ import { useTheme, alpha, } from "@mui/material"; -import type { Theme } from "@mui/material/styles"; // Icons import CloseRounded from "@mui/icons-material/CloseRounded"; import SendRounded from "@mui/icons-material/SendRounded"; import StopRounded from "@mui/icons-material/StopRounded"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI -import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; -import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; -import PauseRounded from "@mui/icons-material/PauseRounded"; -import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import MicRounded from "@mui/icons-material/MicRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; @@ -42,591 +34,20 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; // Logic import { streamCopilotChat } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; -import { - parseAssistantMessageSections, - parseContentWithToolCalls, - type ContentSegment, -} from "./chatMessageSections"; -import { ChatInlineChart } from "./ChatInlineChart"; -import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { useChatToolStore, type ChatToolAction, } from "@/store/chatToolStore"; - -// WebKit Speech Recognition compatibility -interface SpeechRecognitionEvent extends Event { - readonly resultIndex: number; - readonly results: SpeechRecognitionResultList; -} - -interface SpeechRecognition extends EventTarget { - lang: string; - continuous: boolean; - interimResults: boolean; - onresult: ((event: SpeechRecognitionEvent) => void) | null; - onerror: ((event: Event) => void) | null; - onend: (() => void) | null; - start(): void; - stop(): void; - abort(): void; -} - -declare global { - interface Window { - SpeechRecognition?: { new (): SpeechRecognition; prototype: SpeechRecognition }; - webkitSpeechRecognition?: { new (): SpeechRecognition; prototype: SpeechRecognition }; - } -} - -// Types -type Message = { - id: string; - role: "user" | "assistant"; - content: string; - isError?: boolean; -}; - -type Props = { - open: boolean; - onClose: () => void; -}; - -// Utils -const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1"; -const THINK_TAG_ALIAS_PATTERN = /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; -const normalizeThoughtTagToken = (token: string): string => - token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) => - closingSlash ? "</think>" : "<think>", - ); - -type SpeechState = "idle" | "playing" | "paused"; - -const stripMarkdown = (md: string): string => - md - .replace(/```[\s\S]*?```/g, "") - .replace(/`([^`]+)`/g, "$1") - .replace(/!\[.*?\]\(.*?\)/g, "") - .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") - .replace(/#{1,6}\s+/g, "") - .replace(/\*\*\*(.+?)\*\*\*/g, "$1") - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/~~(.+?)~~/g, "$1") - .replace(/>\s+/g, "") - .replace(/[-*+]\s+/g, "") - .replace(/\d+\.\s+/g, "") - .replace(/\n{2,}/g, "\n") - .replace(/<[^>]+>/g, "") - .trim(); - -type PersistedChatState = { - messages: Message[]; - conversationId?: string; -}; - -const PRESET_PROMPTS = [ - "帮我分析当前管网压力异常点,并按风险等级排序。", - "基于当前状态,给出今天的巡检优先级和建议路线。", - "帮我生成一份今日运行简报,包含问题、原因和建议。", -]; - -const getInitialChatState = (): PersistedChatState => { - if (typeof window === "undefined") { - return { messages: [], conversationId: undefined }; - } - try { - const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); - if (!storedRaw) return { messages: [], conversationId: undefined }; - const parsed = JSON.parse(storedRaw) as PersistedChatState; - if (!Array.isArray(parsed.messages)) { - console.error("[GlobalChatbox] Invalid persisted messages format."); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], conversationId: undefined }; - } - return { messages: parsed.messages, conversationId: parsed.conversationId }; - } catch (error) { - console.error("[GlobalChatbox] Failed to read persisted chat state:", error); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], conversationId: undefined }; - } -}; - -// --- Components --- - -const TypingIndicator = () => { - return ( - <Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}> - {[0, 1, 2].map((i) => ( - <motion.div - key={i} - initial={{ y: 0 }} - animate={{ y: [-4, 4, -4] }} - transition={{ - duration: 0.6, - repeat: Infinity, - delay: i * 0.15, - ease: "easeInOut", // Smooth sine wave - }} - > - <Box - sx={{ - width: 8, - height: 8, - borderRadius: "50%", - background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)", // Warm gradient dots - }} - /> - </motion.div> - ))} - </Stack> - ); -}; - -// Animated Background Blob -const Blob = ({ color, size, top, left, delay }: { color: string; size: number; top: string; left: string; delay: number }) => ( - <motion.div - initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }} - animate={{ - scale: [0.8, 1.2, 0.8], - opacity: [0.3, 0.5, 0.3], - x: [0, 30, 0], - y: [0, -30, 0], - }} - transition={{ - duration: 8, - repeat: Infinity, - ease: "easeInOut", - delay: delay, - }} - style={{ - position: "absolute", - top, - left, - width: size, - height: size, - borderRadius: "50%", - background: color, - filter: "blur(60px)", - zIndex: 0, - pointerEvents: "none", - }} - /> -); - -type ChatMessageItemProps = { - message: Message; - theme: Theme; - messageSpeechState: SpeechState; - onSpeak: (messageId: string, text: string) => void; - onPause: () => void; - onResume: () => void; - onStopSpeech: () => void; - isTtsSupported: boolean; - sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>; -}; - -const ChatMessageItem = React.memo( - ({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, sseChartParams }: ChatMessageItemProps) => { - const isUser = message.role === "user"; - const isErrorMessage = Boolean(message.isError); - const parsedAssistantSections = - !isUser && !isErrorMessage - ? parseAssistantMessageSections(message.content) - : null; - const answerContent = parsedAssistantSections?.answer ?? message.content; - - // Parse tool_call blocks from the answer for inline rendering - const contentSegments: ContentSegment[] = - !isUser && !isErrorMessage - ? parseContentWithToolCalls(answerContent).segments - : [{ type: "text", content: answerContent }]; - - return ( - <motion.div - initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - exit={{ opacity: 0, scale: 0.8 }} - transition={{ type: "spring", stiffness: 350, damping: 25 }} - style={{ - alignSelf: isUser ? "flex-end" : "flex-start", - maxWidth: "85%", - display: "flex", - flexDirection: isUser ? "row-reverse" : "row", - gap: 12, - alignItems: "flex-end", - }} - > - {!isUser && ( - <Avatar sx={{ width: 28, height: 28, bgcolor: isErrorMessage ? alpha(theme.palette.error.main, 0.12) : alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}> - {isErrorMessage ? ( - <ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} /> - ) : ( - <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> - )} - </Avatar> - )} - - <Box> - <Paper - elevation={isUser ? 8 : isErrorMessage ? 1 : 2} - sx={{ - p: 2.5, - borderRadius: 4, - borderBottomRightRadius: isUser ? 4 : 24, - borderBottomLeftRadius: !isUser ? 4 : 24, - bgcolor: isUser ? "primary.main" : isErrorMessage ? alpha(theme.palette.error.light, 0.18) : "#fff", - color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary", - background: isUser - ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` - : isErrorMessage - ? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})` - : undefined, - border: isErrorMessage ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` : "none", - boxShadow: isUser - ? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}` - : isErrorMessage - ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` - : `0 4px 16px -4px ${alpha("#000", 0.05)}`, - "--chat-md-text": isUser - ? alpha("#fff", 0.96) - : isErrorMessage - ? theme.palette.error.dark - : "#1f2937", - "--chat-md-heading": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#111827", - "--chat-md-link": isUser - ? "#E3F2FD" - : isErrorMessage - ? theme.palette.error.main - : "#7C3AED", - "--chat-md-link-hover": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#6D28D9", - "--chat-md-inline-code-bg": isUser - ? "rgba(255,255,255,0.2)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#EEF2FF", - "--chat-md-inline-code-border": isUser - ? alpha("#fff", 0.16) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.25) - : "#CBD5E1", - "--chat-md-inline-code-text": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#334155", - "--chat-md-pre-bg": isUser - ? "rgba(11, 18, 32, 0.56)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#111827", - "--chat-md-pre-border": isUser - ? alpha("#fff", 0.12) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.3) - : "#64748B", - "--chat-md-pre-text": isUser - ? "#F8FAFC" - : isErrorMessage - ? theme.palette.error.dark - : "#E5E7EB", - "--chat-md-quote-border": isErrorMessage - ? alpha(theme.palette.error.main, 0.5) - : isUser - ? alpha("#fff", 0.5) - : "#7C3AED", - "--chat-md-quote-bg": isUser - ? alpha("#fff", 0.08) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.06) - : "#F5F3FF", - "--chat-md-quote-text": isUser - ? alpha("#fff", 0.9) - : isErrorMessage - ? theme.palette.error.dark - : "#475569", - }} - > - {contentSegments.map((segment, segIdx) => { - if (segment.type === "text") { - const text = segment.content.trim(); - if (!text && contentSegments.length > 1) return null; - return ( - <div key={segIdx} className={markdownStyles.markdown}> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {text || "..."} - </ReactMarkdown> - </div> - ); - } - if (segment.type === "tool_call") { - if (segment.toolCall.tool === "chart") { - return ( - <ChatInlineChart - key={segment.toolCall.id} - {...(segment.toolCall.params as Record<string, unknown>)} - /> - ); - } - if (segment.toolCall.tool === "show_chart") { - const p = segment.toolCall.params; - return ( - <ChatInlineChart - key={segment.toolCall.id} - title={(p.title as string) ?? undefined} - chart_type={(p.chart_type as "line" | "bar" | "pie") ?? "line"} - x_data={(p.x_data as string[]) ?? []} - series={(p.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []} - x_axis_name={(p.x_axis_name as string) ?? undefined} - y_axis_name={(p.y_axis_name as string) ?? undefined} - /> - ); - } - return ( - <ChatToolCallBlock - key={segment.toolCall.id} - toolCall={segment.toolCall} - /> - ); - } - if (segment.type === "tool_call_pending") { - return ( - <motion.div - key="tool-pending" - initial={{ opacity: 0 }} - animate={{ opacity: [0.4, 1, 0.4] }} - transition={{ - duration: 1.5, - repeat: Infinity, - ease: "easeInOut", - }} - style={{ - marginTop: 8, - display: "flex", - alignItems: "center", - gap: 8, - }} - > - <AutoAwesome - sx={{ fontSize: 14, color: "primary.main" }} - /> - <Typography variant="caption" color="text.secondary"> - 正在准备工具调用... - </Typography> - </motion.div> - ); - } - return null; - })} - {/* SSE-sourced inline charts (from show_chart tool_call events) */} - {sseChartParams?.map((chart, idx) => ( - <ChatInlineChart - key={`sse-chart-${idx}`} - title={(chart.params.title as string) ?? undefined} - chart_type={(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"} - x_data={(chart.params.x_data as string[]) ?? []} - series={(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []} - x_axis_name={(chart.params.x_axis_name as string) ?? undefined} - y_axis_name={(chart.params.y_axis_name as string) ?? undefined} - /> - ))} - </Paper> - {!isUser && !isErrorMessage && isTtsSupported && ( - <Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}> - {messageSpeechState === "idle" && ( - <IconButton - size="small" - onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} - aria-label="朗读消息" - sx={{ color: "text.secondary", opacity: 0.6, "&:hover": { opacity: 1 }, p: 0.5 }} - > - <VolumeUpRounded sx={{ fontSize: 16 }} /> - </IconButton> - )} - {messageSpeechState === "playing" && ( - <> - <IconButton - size="small" - onClick={onPause} - aria-label="暂停朗读" - sx={{ color: "primary.main", p: 0.5 }} - > - <PauseRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton - size="small" - onClick={onStopSpeech} - aria-label="停止朗读" - sx={{ color: "error.main", p: 0.5 }} - > - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> - )} - {messageSpeechState === "paused" && ( - <> - <IconButton - size="small" - onClick={onResume} - aria-label="继续朗读" - sx={{ color: "primary.main", p: 0.5 }} - > - <PlayArrowRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton - size="small" - onClick={onStopSpeech} - aria-label="停止朗读" - sx={{ color: "error.main", p: 0.5 }} - > - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> - )} - </Stack> - )} - </Box> - </motion.div> - ); - }, -); -ChatMessageItem.displayName = "ChatMessageItem"; - -// --- Voice Hooks --- - -function useSpeechSynthesis() { - const [speechState, setSpeechState] = useState<SpeechState>("idle"); - const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null); - const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null); - - const isSupported = typeof window !== "undefined" && "speechSynthesis" in window; - - const stop = useCallback(() => { - if (!isSupported) return; - window.speechSynthesis.cancel(); - utteranceRef.current = null; - setSpeechState("idle"); - setSpeakingMessageId(null); - }, [isSupported]); - - const speak = useCallback( - (messageId: string, text: string) => { - if (!isSupported || !text) return; - window.speechSynthesis.cancel(); - - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = "zh-CN"; - utterance.rate = 1; - utterance.onend = () => { - setSpeechState("idle"); - setSpeakingMessageId(null); - utteranceRef.current = null; - }; - utterance.onerror = () => { - setSpeechState("idle"); - setSpeakingMessageId(null); - utteranceRef.current = null; - }; - utterance.onpause = () => setSpeechState("paused"); - utterance.onresume = () => setSpeechState("playing"); - - utteranceRef.current = utterance; - setSpeakingMessageId(messageId); - setSpeechState("playing"); - window.speechSynthesis.speak(utterance); - }, - [isSupported], - ); - - const pause = useCallback(() => { - if (!isSupported) return; - window.speechSynthesis.pause(); - }, [isSupported]); - - const resume = useCallback(() => { - if (!isSupported) return; - window.speechSynthesis.resume(); - }, [isSupported]); - - useEffect(() => { - return () => { - if (typeof window !== "undefined" && "speechSynthesis" in window) { - window.speechSynthesis.cancel(); - } - }; - }, []); - - return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported }; -} - -function useSpeechRecognition(onResult: (text: string) => void) { - const [isListening, setIsListening] = useState(false); - const recognitionRef = useRef<SpeechRecognition | null>(null); - const onResultRef = useRef(onResult); - useEffect(() => { - onResultRef.current = onResult; - }, [onResult]); - - const isSupported = - typeof window !== "undefined" && - ("SpeechRecognition" in window || "webkitSpeechRecognition" in window); - - const start = useCallback(() => { - if (!isSupported || recognitionRef.current) return; - const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition; - if (!Ctor) return; - - const recognition = new Ctor(); - recognition.lang = "zh-CN"; - recognition.continuous = true; - recognition.interimResults = false; - - recognition.onresult = (event: SpeechRecognitionEvent) => { - for (let i = event.resultIndex; i < event.results.length; i++) { - if (event.results[i].isFinal) { - onResultRef.current(event.results[i][0].transcript); - } - } - }; - - recognition.onerror = () => { - setIsListening(false); - recognitionRef.current = null; - }; - - recognition.onend = () => { - setIsListening(false); - recognitionRef.current = null; - }; - - recognitionRef.current = recognition; - recognition.start(); - setIsListening(true); - }, [isSupported]); - - const stop = useCallback(() => { - recognitionRef.current?.stop(); - recognitionRef.current = null; - setIsListening(false); - }, []); - - useEffect(() => { - return () => { - recognitionRef.current?.stop(); - }; - }, []); - - return { isListening, start, stop, isSupported }; -} +import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types"; +import { + CHAT_STORAGE_KEY, + PRESET_PROMPTS, + createId, + getInitialChatState, + normalizeThoughtTagToken, +} from "./GlobalChatbox.utils"; +import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts"; +import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"; export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const initialChatStateRef = useRef<PersistedChatState | null>(null); diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts new file mode 100644 index 0000000..d7546ce --- /dev/null +++ b/src/components/chat/GlobalChatbox.types.ts @@ -0,0 +1,18 @@ +export type Message = { + id: string; + role: "user" | "assistant"; + content: string; + isError?: boolean; +}; + +export type Props = { + open: boolean; + onClose: () => void; +}; + +export type SpeechState = "idle" | "playing" | "paused"; + +export type PersistedChatState = { + messages: Message[]; + conversationId?: string; +}; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts new file mode 100644 index 0000000..0bab75b --- /dev/null +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -0,0 +1,54 @@ +import type { PersistedChatState } from "./GlobalChatbox.types"; + +export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +export const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1"; +const THINK_TAG_ALIAS_PATTERN = /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; +export const PRESET_PROMPTS = [ + "帮我分析当前管网压力异常点,并按风险等级排序。", + "基于当前状态,给出今天的巡检优先级和建议路线。", + "帮我生成一份今日运行简报,包含问题、原因和建议。", +]; + +export const normalizeThoughtTagToken = (token: string): string => + token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) => + closingSlash ? "</think>" : "<think>", + ); + +export const stripMarkdown = (md: string): string => + md + .replace(/```[\s\S]*?```/g, "") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[.*?\]\(.*?\)/g, "") + .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") + .replace(/#{1,6}\s+/g, "") + .replace(/\*\*\*(.+?)\*\*\*/g, "$1") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/>\s+/g, "") + .replace(/[-*+]\s+/g, "") + .replace(/\d+\.\s+/g, "") + .replace(/\n{2,}/g, "\n") + .replace(/<[^>]+>/g, "") + .trim(); + +export const getInitialChatState = (): PersistedChatState => { + if (typeof window === "undefined") { + return { messages: [], conversationId: undefined }; + } + try { + const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); + if (!storedRaw) return { messages: [], conversationId: undefined }; + const parsed = JSON.parse(storedRaw) as PersistedChatState; + if (!Array.isArray(parsed.messages)) { + console.error("[GlobalChatbox] Invalid persisted messages format."); + window.localStorage.removeItem(CHAT_STORAGE_KEY); + return { messages: [], conversationId: undefined }; + } + return { messages: parsed.messages, conversationId: parsed.conversationId }; + } catch (error) { + console.error("[GlobalChatbox] Failed to read persisted chat state:", error); + window.localStorage.removeItem(CHAT_STORAGE_KEY); + return { messages: [], conversationId: undefined }; + } +}; diff --git a/src/components/chat/GlobalChatbox.voice.ts b/src/components/chat/GlobalChatbox.voice.ts new file mode 100644 index 0000000..cce50d9 --- /dev/null +++ b/src/components/chat/GlobalChatbox.voice.ts @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { SpeechState } from "./GlobalChatbox.types"; + +// WebKit Speech Recognition compatibility +interface SpeechRecognitionEvent extends Event { + readonly resultIndex: number; + readonly results: SpeechRecognitionResultList; +} + +interface SpeechRecognition extends EventTarget { + lang: string; + continuous: boolean; + interimResults: boolean; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onerror: ((event: Event) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; + abort(): void; +} + +declare global { + interface Window { + SpeechRecognition?: { + new (): SpeechRecognition; + prototype: SpeechRecognition; + }; + webkitSpeechRecognition?: { + new (): SpeechRecognition; + prototype: SpeechRecognition; + }; + } +} + +export function useSpeechSynthesis() { + const [speechState, setSpeechState] = useState<SpeechState>("idle"); + const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null); + const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null); + + const isSupported = typeof window !== "undefined" && "speechSynthesis" in window; + + const stop = useCallback(() => { + if (!isSupported) return; + window.speechSynthesis.cancel(); + utteranceRef.current = null; + setSpeechState("idle"); + setSpeakingMessageId(null); + }, [isSupported]); + + const speak = useCallback( + (messageId: string, text: string) => { + if (!isSupported || !text) return; + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = "zh-CN"; + utterance.rate = 1; + utterance.onend = () => { + setSpeechState("idle"); + setSpeakingMessageId(null); + utteranceRef.current = null; + }; + utterance.onerror = () => { + setSpeechState("idle"); + setSpeakingMessageId(null); + utteranceRef.current = null; + }; + utterance.onpause = () => setSpeechState("paused"); + utterance.onresume = () => setSpeechState("playing"); + + utteranceRef.current = utterance; + setSpeakingMessageId(messageId); + setSpeechState("playing"); + window.speechSynthesis.speak(utterance); + }, + [isSupported], + ); + + const pause = useCallback(() => { + if (!isSupported) return; + window.speechSynthesis.pause(); + }, [isSupported]); + + const resume = useCallback(() => { + if (!isSupported) return; + window.speechSynthesis.resume(); + }, [isSupported]); + + useEffect(() => { + return () => { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + window.speechSynthesis.cancel(); + } + }; + }, []); + + return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported }; +} + +export function useSpeechRecognition(onResult: (text: string) => void) { + const [isListening, setIsListening] = useState(false); + const recognitionRef = useRef<SpeechRecognition | null>(null); + const onResultRef = useRef(onResult); + useEffect(() => { + onResultRef.current = onResult; + }, [onResult]); + + const isSupported = + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window); + + const start = useCallback(() => { + if (!isSupported || recognitionRef.current) return; + const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition; + if (!Ctor) return; + + const recognition = new Ctor(); + recognition.lang = "zh-CN"; + recognition.continuous = true; + recognition.interimResults = false; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + for (let i = event.resultIndex; i < event.results.length; i++) { + if (event.results[i].isFinal) { + onResultRef.current(event.results[i][0].transcript); + } + } + }; + + recognition.onerror = () => { + setIsListening(false); + recognitionRef.current = null; + }; + + recognition.onend = () => { + setIsListening(false); + recognitionRef.current = null; + }; + + recognitionRef.current = recognition; + recognition.start(); + setIsListening(true); + }, [isSupported]); + + const stop = useCallback(() => { + recognitionRef.current?.stop(); + recognitionRef.current = null; + setIsListening(false); + }, []); + + useEffect(() => { + return () => { + recognitionRef.current?.stop(); + }; + }, []); + + return { isListening, start, stop, isSupported }; +} -- 2.54.0 From f0fad61bb2d8da70dfba916380b6f469ae44dffa Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 3 Apr 2026 14:08:59 +0800 Subject: [PATCH 071/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.utils.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index 0bab75b..ff0b069 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -1,11 +1,13 @@ import type { PersistedChatState } from "./GlobalChatbox.types"; -export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +export const createId = () => + `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; export const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1"; -const THINK_TAG_ALIAS_PATTERN = /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; +const THINK_TAG_ALIAS_PATTERN = + /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; export const PRESET_PROMPTS = [ + "分析当前管网中的水力瓶颈管道,并给出改造建议。", "帮我分析当前管网压力异常点,并按风险等级排序。", - "基于当前状态,给出今天的巡检优先级和建议路线。", "帮我生成一份今日运行简报,包含问题、原因和建议。", ]; @@ -47,7 +49,10 @@ export const getInitialChatState = (): PersistedChatState => { } return { messages: parsed.messages, conversationId: parsed.conversationId }; } catch (error) { - console.error("[GlobalChatbox] Failed to read persisted chat state:", error); + console.error( + "[GlobalChatbox] Failed to read persisted chat state:", + error, + ); window.localStorage.removeItem(CHAT_STORAGE_KEY); return { messages: [], conversationId: undefined }; } -- 2.54.0 From 7d05ad492062c03d3c791635b79726286e74eef7 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 7 Apr 2026 09:45:06 +0800 Subject: [PATCH 072/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20@refinedev=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=EF=BC=9B?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 959 ++++++++++++++++++++++++++++++++++++---------- package.json | 8 +- 2 files changed, 764 insertions(+), 203 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4064837..a8810eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,7 @@ "@mui/x-charts": "^7.29.1", "@mui/x-data-grid": "^7.22.2", "@mui/x-date-pickers": "^8.12.0", - "@refinedev/cli": "^2.16.50", "@refinedev/core": "^5.0.8", - "@refinedev/devtools": "^2.0.3", "@refinedev/kbar": "^2.0.1", "@refinedev/mui": "^8.0.0", "@refinedev/nextjs-router": "^7.0.4", @@ -48,6 +46,12 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@refinedev/cli": "^2.16.52", + "@refinedev/devtools": "^2.0.5", + "@refinedev/devtools-internal": "^2.0.2", + "@refinedev/devtools-server": "^2.0.2", + "@refinedev/devtools-shared": "^2.0.2", + "@refinedev/devtools-ui": "^2.0.3", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -82,6 +86,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@aliemir/dom-to-fiber-utils/-/dom-to-fiber-utils-0.4.1.tgz", "integrity": "sha512-mWjCp9Uu3B1Rbtdnk23Coak831zgy+/1oS+FCTVhCAozGPUURE8IfVFQsCblZMWuT5j+QSc6JNDQ+l2JcZclzA==", + "dev": true, "dependencies": { "react": "^18.0.0", "react-dom": "^18.0.0", @@ -92,6 +97,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -104,6 +110,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -117,6 +124,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -166,6 +174,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -175,6 +184,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -205,12 +215,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -236,6 +248,7 @@ "version": "7.27.3", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.27.3" @@ -248,6 +261,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -264,6 +278,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -273,6 +288,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -282,12 +298,14 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -309,6 +327,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -372,6 +391,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -398,6 +418,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -415,6 +436,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.27.1" @@ -424,9 +446,10 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -454,6 +477,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-member-expression-to-functions": "^7.27.1", @@ -471,6 +495,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -502,6 +527,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -526,6 +552,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -703,12 +730,13 @@ } }, "node_modules/@babel/plugin-syntax-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", - "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -779,6 +807,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -904,6 +933,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1020,6 +1050,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", @@ -1223,6 +1254,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -1355,6 +1387,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.27.1", @@ -1440,6 +1473,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1524,6 +1558,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -1556,6 +1591,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", @@ -1821,6 +1857,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -2002,6 +2039,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", "integrity": "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -2055,6 +2093,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -2071,9 +2110,10 @@ } }, "node_modules/@babel/register": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.28.3.tgz", - "integrity": "sha512-CieDOtd8u208eI49bYl4z1J22ySFw87IGwE+IswFEExH7e3rLgKb0WNQeumnacQ1+VoDJLYI5QFA3AJZuyZQfA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.28.6.tgz", + "integrity": "sha512-pgcbbEl/dWQYb6L6Yew6F94rdwygfuv+vJ/tXfwIOYAfPB6TNWpXUMEtEq3YuTeHRdvMIhvz13bkT9CNaS+wqA==", + "dev": true, "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", @@ -2186,6 +2226,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2918,6 +2959,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fireworks-js/react": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/@fireworks-js/react/-/react-2.10.8.tgz", + "integrity": "sha512-Qy/HSiTnph2IT2LHVzo9Ov+zifGQboTICjVLyrp/sWMHZoaodZiW0xrFxN1RfYI3R7j5bNZmf6S9EzqOWPfFWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fireworks-js": "2.10.8" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -2956,6 +3011,24 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@headlessui/react": { + "version": "1.7.19", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", + "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", + "client-only": "^0.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3478,6 +3551,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.0", @@ -3499,6 +3573,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -3515,6 +3590,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -3532,6 +3608,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3544,6 +3621,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3556,6 +3634,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -3573,6 +3652,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -3588,6 +3668,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -5569,6 +5650,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -5582,6 +5664,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -5591,6 +5674,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -5614,6 +5698,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/promise-spawn": "^7.0.0", @@ -5634,6 +5719,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.1.tgz", "integrity": "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==", + "dev": true, "license": "ISC", "dependencies": { "@npmcli/git": "^5.0.0", @@ -5652,6 +5738,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5664,6 +5751,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", + "dev": true, "license": "ISC", "dependencies": { "which": "^4.0.0" @@ -5691,6 +5779,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -5915,13 +6004,14 @@ } }, "node_modules/@refinedev/cli": { - "version": "2.16.50", - "resolved": "https://registry.npmjs.org/@refinedev/cli/-/cli-2.16.50.tgz", - "integrity": "sha512-cPxCUB6vjYKbBbEQqY2JLENmPE9fneqTB7ZQRv7Hy1teF+hn4Q0iNH24C07JHZ5LSXnH98SXhgnUeUvNLRXPFQ==", + "version": "2.16.52", + "resolved": "https://registry.npmjs.org/@refinedev/cli/-/cli-2.16.52.tgz", + "integrity": "sha512-rr8QvL5ngpwqoD89unr1walLxGRHwQRC+bs9DYfPXiQM5oS8GNqo5GSQr70m1yZZssD1cNrIPvCuO67pkJxiYg==", + "dev": true, "license": "MIT", "dependencies": { "@npmcli/package-json": "^5.2.0", - "@refinedev/devtools-server": "2.0.1", + "@refinedev/devtools-server": "2.0.2", "boxen": "^5.1.2", "camelcase": "^6.2.0", "cardinal": "^2.1.1", @@ -5991,32 +6081,7 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@refinedev/devtools": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@refinedev/devtools/-/devtools-2.0.3.tgz", - "integrity": "sha512-cJLrjJ7Rwza3g0UMdhSEA7ZK3faWvSN1lHt4aXgL30nY/ruvgL1yK2L+t3OQUZolOsPRdbgpvpmlueHLXo+XyQ==", - "license": "MIT", - "dependencies": { - "@aliemir/dom-to-fiber-utils": "^0.4.0", - "@refinedev/devtools-shared": "2.0.1", - "error-stack-parser": "^2.1.4", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@refinedev/cli": "2.16.50", - "@refinedev/core": "^5.0.0", - "@refinedev/devtools-server": "2.0.1", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@refinedev/devtools-internal": { + "node_modules/@refinedev/core/node_modules/@refinedev/devtools-internal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@refinedev/devtools-internal/-/devtools-internal-2.0.1.tgz", "integrity": "sha512-B28TrwJoQ+afm2jC74r96jgaAQXhc4SHYnpenJSyMrv0nxL3Trnr+QnuRxSIjOaYm7y9pACpX8lqbzIouWPCfg==", @@ -6036,13 +6101,80 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@refinedev/devtools-server": { + "node_modules/@refinedev/core/node_modules/@refinedev/devtools-shared": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@refinedev/devtools-server/-/devtools-server-2.0.1.tgz", - "integrity": "sha512-BgP0A+Ag4njZ5I2GKCEYaLbYZ5AvDBBaL5xhlDnYxSwVQI7yMi5tH5vFsr2cveW5MyuLZBn7KDTyKYUVO3EnAQ==", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.1.tgz", + "integrity": "sha512-x9Eg7wdwx8AB9LgN9Gg/bW4icpdIYCoJLCXZA2kOoY77+CHmUY6Uv78RgimvkIGEHZucbchJLCFoLdZkvx5ceQ==", "license": "MIT", "dependencies": { - "@refinedev/devtools-shared": "2.0.1", + "@tanstack/react-query": "^5.81.5", + "error-stack-parser": "^2.1.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/devtools": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@refinedev/devtools/-/devtools-2.0.5.tgz", + "integrity": "sha512-D3jQlB3VZ+evRI2wmy+0TMwY78mRR3EwECNzWpkETeg8rK/Ovjo4t6FMUKAke7qht3Zdlkc6zGv+V/vWK3IMfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aliemir/dom-to-fiber-utils": "^0.4.0", + "@refinedev/devtools-shared": "2.0.2", + "error-stack-parser": "^2.1.4", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@refinedev/cli": "2.16.52", + "@refinedev/core": "^5.0.0", + "@refinedev/devtools-server": "2.0.2", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/devtools-internal": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-internal/-/devtools-internal-2.0.2.tgz", + "integrity": "sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@refinedev/devtools-shared": "2.0.2", + "@tanstack/react-query": "^5.81.5", + "error-stack-parser": "^2.1.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/devtools-server": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-server/-/devtools-server-2.0.2.tgz", + "integrity": "sha512-xiS9ROvwws/iqDykGps4hliPROYkgJMTZTHEt4llwJ7rlXxRpXyF316n2wWZUakWSBXRYxgIkIvdDeAE7Uu1Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@refinedev/devtools-shared": "2.0.2", "body-parser": "^1.20.2", "boxen": "^5.1.2", "chalk": "^4.1.2", @@ -6080,9 +6212,10 @@ } }, "node_modules/@refinedev/devtools-shared": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.1.tgz", - "integrity": "sha512-x9Eg7wdwx8AB9LgN9Gg/bW4icpdIYCoJLCXZA2kOoY77+CHmUY6Uv78RgimvkIGEHZucbchJLCFoLdZkvx5ceQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.2.tgz", + "integrity": "sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA==", + "dev": true, "license": "MIT", "dependencies": { "@tanstack/react-query": "^5.81.5", @@ -6098,6 +6231,47 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@refinedev/devtools-ui": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-ui/-/devtools-ui-2.0.3.tgz", + "integrity": "sha512-jxedCR6/LjdfifNl6D/QQseWH4cAHMJJ8BQKCLPnbxjIjnHgvRc0wr7pIpXLrpaFZumBCA4YvV1/xVw4z2sA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fireworks-js/react": "^2.10.7", + "@headlessui/react": "^1.7.17", + "@refinedev/devtools-shared": "2.0.2", + "@tanstack/react-table": "^8.2.6", + "clsx": "^1.1.1", + "dayjs": "^1.10.7", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "prism-react-renderer": "^1.3.5", + "react-hook-form": "^7.57.0", + "react-json-view-lite": "^1.3.0", + "react-router": "^7.0.2", + "semver-diff": "^3.1.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/devtools-ui/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@refinedev/kbar": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@refinedev/kbar/-/kbar-2.0.1.tgz", @@ -7039,6 +7213,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7634,6 +7809,70 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -9970,6 +10209,7 @@ "version": "1.17.17", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -10400,9 +10640,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -10759,6 +10999,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, "license": "MIT", "dependencies": { "mime-types": "~2.1.34", @@ -10805,6 +11046,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -10821,6 +11063,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -10838,6 +11081,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/align-text/-/align-text-1.0.2.tgz", "integrity": "sha512-uBPDs72zrRTdiTBY0YjBbuBOdXtRyT4qsKPb4bL4O7vH4utz/7KjwTJVsVbdThxMbVzkRGAfk8Ml3xoMvXSEYw==", + "dev": true, "license": "MIT", "dependencies": { "kind-of": "^5.0.2", @@ -10852,6 +11096,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.1.0" @@ -10861,6 +11106,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -10876,6 +11122,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -10888,6 +11135,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10897,6 +11145,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -10912,6 +11161,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { @@ -10966,6 +11216,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, "license": "MIT" }, "node_modules/array-includes": { @@ -10995,6 +11246,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11124,6 +11376,7 @@ "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.1" @@ -11159,6 +11412,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.12.0" @@ -11407,12 +11661,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "devOptional": true, "funding": [ { "type": "github", @@ -11451,6 +11707,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -11462,6 +11719,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -11476,6 +11734,7 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -11500,6 +11759,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -11509,6 +11769,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, "license": "MIT" }, "node_modules/boolbase": { @@ -11522,6 +11783,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-align": "^3.0.0", @@ -11541,9 +11803,10 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -11554,6 +11817,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -11576,6 +11840,7 @@ "version": "4.26.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -11641,6 +11906,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -11665,12 +11931,14 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -11737,6 +12005,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -11769,6 +12038,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, "license": "MIT", "dependencies": { "ansicolors": "~0.3.2", @@ -11801,6 +12071,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/center-align/-/center-align-1.0.1.tgz", "integrity": "sha512-j6Ba1Vwtu0i9CUfM5VicnMqOsNRMYnNAoTUTB/EzUFhBKkqFPD5UE2WTCSIy49OnbjTEnJ0t2CFPYMbKNrUi/A==", + "dev": true, "license": "MIT", "dependencies": { "align-text": "^1.0.0", @@ -11814,6 +12085,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -11830,6 +12102,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -11879,6 +12152,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "dev": true, "license": "MIT" }, "node_modules/charenc": { @@ -11926,6 +12200,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11938,6 +12213,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -11950,6 +12226,7 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11962,6 +12239,7 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, "license": "MIT", "dependencies": { "string-width": "^4.2.0" @@ -11977,6 +12255,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, "license": "ISC", "engines": { "node": ">= 10" @@ -12007,6 +12286,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8" @@ -12016,6 +12296,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, "license": "MIT", "dependencies": { "is-plain-object": "^2.0.4", @@ -12030,6 +12311,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, "license": "MIT", "dependencies": { "isobject": "^3.0.1" @@ -12042,6 +12324,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12078,6 +12361,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -12090,6 +12374,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/colorbrewer": { @@ -12135,6 +12420,7 @@ "version": "9.4.1", "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || >=14" @@ -12144,12 +12430,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/concaveman": { @@ -12168,6 +12456,7 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.6.3", @@ -12192,6 +12481,7 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -12204,6 +12494,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -12228,6 +12519,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, "license": "MIT" }, "node_modules/core-assert": { @@ -12313,6 +12605,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -12327,12 +12620,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -12746,6 +13041,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^3.0.0" @@ -12778,6 +13074,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12859,6 +13156,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true, "license": "MIT" }, "node_modules/deep-is": { @@ -12884,6 +13182,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12893,6 +13192,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -12965,6 +13265,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -12983,6 +13284,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8", @@ -13025,6 +13327,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, "license": "MIT", "dependencies": { "path-type": "^4.0.0" @@ -13067,6 +13370,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -13081,6 +13385,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -13093,6 +13398,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -13108,6 +13414,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -13133,6 +13440,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "dev": true, "license": "MIT", "dependencies": { "is-obj": "^2.0.0" @@ -13148,6 +13456,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -13186,6 +13495,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, "license": "MIT" }, "node_modules/echarts": { @@ -13222,12 +13532,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.227", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", "integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==", + "dev": true, "license": "ISC" }, "node_modules/emittery": { @@ -13247,18 +13559,21 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/emojilib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13281,6 +13596,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -13293,6 +13609,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13302,6 +13619,7 @@ "version": "7.15.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.15.0.tgz", "integrity": "sha512-chR+t7exF6y59kelhXw5I3849nTy7KIRO+ePdLMhCD+JRP/JvmkenDWP7QSFGlsHX+kxGxdDutOPrmj5j1HR6g==", + "dev": true, "license": "MIT", "bin": { "envinfo": "dist/cli.js" @@ -13314,6 +13632,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, "license": "MIT" }, "node_modules/error-ex": { @@ -13511,6 +13830,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13520,6 +13840,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -13960,6 +14281,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -14036,6 +14358,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14045,12 +14368,14 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, "license": "MIT" }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -14102,6 +14427,7 @@ "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -14148,6 +14474,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -14157,6 +14484,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, "license": "MIT" }, "node_modules/extend": { @@ -14169,6 +14497,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, "license": "MIT", "dependencies": { "is-extendable": "^0.1.0" @@ -14193,6 +14522,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -14209,6 +14539,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -14235,6 +14566,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, "funding": [ { "type": "github", @@ -14286,6 +14618,7 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -14329,6 +14662,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.9.3.tgz", "integrity": "sha512-majPgOpVtrZN1iyNGbsUP6bOtZ6eaJgg5HHh0vFvm5DJhh8dc+FJpOC4GABvMZ/A7XHAJUuJujhgUY/2jPWgMA==", + "dev": true, "license": "MIT", "dependencies": { "commander": "^14.0.0" @@ -14344,6 +14678,7 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -14353,6 +14688,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" @@ -14368,6 +14704,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -14390,6 +14727,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -14411,6 +14749,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -14429,6 +14768,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -14438,12 +14778,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, "license": "MIT" }, "node_modules/find-cache-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, "license": "MIT", "dependencies": { "commondir": "^1.0.1", @@ -14464,6 +14806,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -14480,6 +14823,7 @@ "version": "1.2.16", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root2/-/find-yarn-workspace-root2-1.2.16.tgz", "integrity": "sha512-hr6hb1w8ePMpPVUK39S4RlwJzi+xPLuVuG8XlwXU3KD5Yn3qgBWVfy3AzNlDhWvE1EORCE65/Qm26rFQt3VLVA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "micromatch": "^4.0.2", @@ -14490,6 +14834,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -14503,6 +14848,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -14515,6 +14861,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -14530,6 +14877,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -14542,6 +14890,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -14550,6 +14899,13 @@ "node": ">=8" } }, + "node_modules/fireworks-js": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/fireworks-js/-/fireworks-js-2.10.8.tgz", + "integrity": "sha512-UZNxeJvRmQzLisN4iriWXqKojG9TDJqc0dPmkUw0/+AEQQ3w8z1Jx2YdFSiBGSVb/u4dPTQXU109GMVblzhfpg==", + "dev": true, + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -14572,9 +14928,10 @@ "license": "ISC" }, "node_modules/flow-parser": { - "version": "0.293.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.293.0.tgz", - "integrity": "sha512-8tEGAcWpCqioajiSqrJr2+JSmkEI2vO/UACFGG378RO106ez9xugVxe9EpXD3aI1Vbf+mEUGhMt0gMpveJwVGA==", + "version": "0.308.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.308.0.tgz", + "integrity": "sha512-GYy1GfA6UeXM8gIlQQ4FDuZAcE+uzvA2JViWUT8S3aMRwgOZKoaA3Mt++2pJ1P7BfxG9UYhKzuuQsunh/hCu1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -14620,6 +14977,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -14636,6 +14994,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -14664,6 +15023,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14700,6 +15060,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14709,6 +15070,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -14723,6 +15085,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -14784,6 +15147,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -14908,6 +15272,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -14957,6 +15322,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -14987,9 +15353,10 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -14999,6 +15366,7 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -15044,6 +15412,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, "license": "MIT", "dependencies": { "array-union": "^2.1.0", @@ -15082,6 +15451,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, "license": "MIT", "dependencies": { "js-yaml": "^3.13.1", @@ -15097,6 +15467,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -15106,6 +15477,7 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -15119,6 +15491,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15136,9 +15509,10 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -15160,6 +15534,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -15182,6 +15557,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15331,6 +15707,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" @@ -15370,9 +15747,10 @@ } }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -15384,14 +15762,28 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -15412,6 +15804,7 @@ "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, "license": "MIT", "dependencies": { "eventemitter3": "^4.0.0", @@ -15440,6 +15833,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.15", @@ -15471,6 +15865,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -15480,6 +15875,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -15512,6 +15908,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -15644,6 +16041,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -15664,6 +16062,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -15680,6 +16079,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -15695,6 +16095,7 @@ "version": "8.2.7", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "dev": true, "license": "MIT", "dependencies": { "@inquirer/external-editor": "^1.0.0", @@ -15721,6 +16122,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-2.0.1.tgz", "integrity": "sha512-jUHrH0btO7j5r8DTQgANf2CBkTZChoVySD8zF/wp5fZCOLIuUbleXhf4ZY5jNBOc1owA3gdfWtfZuppfYBhcUg==", + "dev": true, "license": "ISC", "dependencies": { "ansi-escapes": "^4.3.2", @@ -15740,6 +16142,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -15778,6 +16181,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -15996,6 +16400,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16005,6 +16410,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16030,6 +16436,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16068,6 +16475,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -16090,6 +16498,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16125,6 +16534,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -16151,6 +16561,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16172,6 +16583,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16236,6 +16648,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16299,6 +16712,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -16363,6 +16777,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=16" @@ -16372,6 +16787,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16512,6 +16928,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -17384,9 +17801,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -17552,6 +17969,7 @@ "version": "17.3.0", "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.3.0.tgz", "integrity": "sha512-LjFrGOIORqXBU+jwfC9nbkjmQfFldtMIoS6d9z2LG/lkmyNXsJAySPT+2SWXJEoE68/bCWcxKpXH37npftgmow==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.7", @@ -17697,6 +18115,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "dev": true, "license": "MIT", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -17706,12 +18125,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -17725,6 +18146,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -17737,6 +18159,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -17813,6 +18236,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18121,6 +18545,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/load-yaml-file/-/load-yaml-file-0.2.0.tgz", "integrity": "sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.1.5", @@ -18136,6 +18561,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -18145,6 +18571,7 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -18158,6 +18585,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -18170,15 +18598,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -18206,6 +18634,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -18231,6 +18660,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", "integrity": "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18272,6 +18702,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/lz-string": { @@ -18310,6 +18741,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^4.0.1", @@ -18323,6 +18755,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver" @@ -18365,6 +18798,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -18377,6 +18811,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-6.2.0.tgz", "integrity": "sha512-ubWhwcBFHnXsjYNsu+Wndpg0zhY4CahSpPlA70PlO0rR9r2sZpkyU+rkCsOWH+KMEkx847UpALON+HWgxowFtw==", + "dev": true, "license": "MIT", "dependencies": { "ansi-escapes": "^6.2.0", @@ -18397,6 +18832,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.16" @@ -18409,6 +18845,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -18794,6 +19231,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -18809,6 +19247,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -18818,12 +19257,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -18833,6 +19274,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -19405,6 +19847,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -19418,6 +19861,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -19451,6 +19895,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -19470,6 +19915,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -19482,6 +19928,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -19518,6 +19965,7 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -19572,6 +20020,7 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, "license": "ISC" }, "node_modules/nanoid": { @@ -19619,6 +20068,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -19628,6 +20078,7 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, "license": "MIT" }, "node_modules/next": { @@ -19758,6 +20209,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.6.0", @@ -19773,6 +20225,7 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/node-env-type/-/node-env-type-0.0.8.tgz", "integrity": "sha512-EXyxUOlwkuoMm6QHgX3zw06tY6NNpXm3Akf9n1zPUX8UD08FTXBQ9gcS0EWbuSPYqtxMneP72QgdPlLFrn2SpA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -19782,6 +20235,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -19809,12 +20263,14 @@ "version": "2.0.21", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, "license": "MIT" }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^7.0.0", @@ -19877,6 +20333,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "semver": "^7.1.1" @@ -19889,6 +20346,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -19898,6 +20356,7 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "dev": true, "license": "ISC", "dependencies": { "hosted-git-info": "^7.0.0", @@ -19913,6 +20372,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz", "integrity": "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==", + "dev": true, "license": "ISC", "dependencies": { "npm-install-checks": "^6.0.0", @@ -19928,6 +20388,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -20155,6 +20616,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -20167,6 +20629,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -20176,6 +20639,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -20191,6 +20655,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -20251,6 +20716,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -20292,6 +20758,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -20307,6 +20774,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -20322,6 +20790,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -20331,12 +20800,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.1.2.tgz", "integrity": "sha512-iePyefLTOm2gEzbaZKSW+eBMjg+UYsQvUKxmvGXAQ987K16efBg10MxIjZs08iyX+DY2/owKY9DIdu193kX33w==", + "dev": true, "license": "MIT" }, "node_modules/pako": { @@ -20422,6 +20893,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "dev": true, "license": "MIT" }, "node_modules/parse5": { @@ -20454,6 +20926,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -20463,6 +20936,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -20487,6 +20961,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -20496,6 +20971,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -20511,6 +20987,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -20524,9 +21001,10 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "dev": true, "license": "MIT" }, "node_modules/path-type": { @@ -20558,9 +21036,10 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -20573,6 +21052,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -20582,6 +21062,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -20591,6 +21072,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, "license": "MIT", "dependencies": { "find-up": "^3.0.0" @@ -20603,6 +21085,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^3.0.0" @@ -20615,6 +21098,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^3.0.0", @@ -20628,6 +21112,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -20643,6 +21128,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.0.0" @@ -20655,6 +21141,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -20664,6 +21151,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, "license": "MIT", "dependencies": { "find-up": "^3.0.0" @@ -20676,6 +21164,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^3.0.0" @@ -20688,6 +21177,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^3.0.0", @@ -20701,6 +21191,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -20716,6 +21207,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.0.0" @@ -20728,6 +21220,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -20837,6 +21330,7 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/preferred-pm/-/preferred-pm-3.1.4.tgz", "integrity": "sha512-lEHd+yEm22jXdCphDrkvIJQU66EuLojPPtvZkpKIkiD+l0DMThF/niqZKJSoU8Vl7iuvtmzyMhir9LdVy5WMnA==", + "dev": true, "license": "MIT", "dependencies": { "find-up": "^5.0.0", @@ -20862,6 +21356,7 @@ "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, "license": "MIT", "bin": { "prettier": "bin-prettier.js" @@ -20879,10 +21374,21 @@ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "license": "MIT" }, + "node_modules/prism-react-renderer": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.3.5.tgz", + "integrity": "sha512-IJ+MSwBWKG+SM3b2SUfdrhC+gu01QkV2KmRQgREThBfSQRoufqRfxfHUxpG1WcaFjP+kojcFyO9Qqtpgt3qLCg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=0.14.9" + } + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -20898,12 +21404,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, "license": "ISC" }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, "license": "MIT", "dependencies": { "err-code": "^2.0.2", @@ -20950,6 +21458,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -21041,6 +21550,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -21079,6 +21589,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -21088,6 +21599,7 @@ "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -21174,6 +21686,19 @@ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, + "node_modules/react-json-view-lite": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-1.5.0.tgz", + "integrity": "sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -21205,6 +21730,7 @@ "version": "0.29.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -21221,11 +21747,49 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" } }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -21299,6 +21863,7 @@ "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, "license": "MIT", "dependencies": { "ast-types": "^0.16.1", @@ -21315,6 +21880,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -21338,6 +21904,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, "license": "MIT", "dependencies": { "esprima": "~4.0.0" @@ -21534,6 +22101,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21543,6 +22111,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, "license": "MIT" }, "node_modules/reselect": { @@ -21626,6 +22195,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -21639,6 +22209,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -21648,6 +22219,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -21659,6 +22231,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -21672,6 +22245,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -21705,6 +22279,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -21714,6 +22289,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -21737,6 +22313,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -21773,6 +22350,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -21835,17 +22413,19 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", - "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.2.tgz", + "integrity": "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg==", + "dev": true, "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", - "htmlparser2": "^8.0.0", + "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" @@ -21884,6 +22464,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, "license": "MIT", "dependencies": { "extend-shallow": "^2.0.1", @@ -21897,6 +22478,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21906,6 +22488,7 @@ "version": "7.5.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -21921,6 +22504,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.3.0" @@ -21933,6 +22517,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -21942,6 +22527,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -21954,12 +22540,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC" }, "node_modules/send": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", - "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -21968,13 +22556,13 @@ "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -21984,6 +22572,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -21993,121 +22582,32 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, "license": "MIT" }, - "node_modules/send/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/serve-static/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-static/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, "license": "MIT" }, - "node_modules/serve-static/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -22167,12 +22667,14 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, "license": "ISC" }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, "license": "MIT", "dependencies": { "kind-of": "^6.0.2" @@ -22185,6 +22687,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -22252,6 +22755,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -22264,6 +22768,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -22345,6 +22850,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/size-sensor": { @@ -22357,6 +22863,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, "license": "MIT", "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" @@ -22375,6 +22882,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -22419,6 +22927,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -22429,6 +22938,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -22448,6 +22958,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -22458,12 +22969,14 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -22474,6 +22987,7 @@ "version": "3.0.22", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true, "license": "CC0-1.0" }, "node_modules/splaytree-ts": { @@ -22537,6 +23051,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -22598,6 +23113,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -22613,6 +23129,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -22627,12 +23144,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/string.prototype.includes": { @@ -22766,6 +23285,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -22779,6 +23299,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -22791,6 +23312,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -22800,6 +23322,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -22809,6 +23332,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -22903,6 +23427,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -22915,6 +23440,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0", @@ -23053,6 +23579,7 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, "license": "MIT", "dependencies": { "mkdirp": "^0.5.1", @@ -23125,6 +23652,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, "license": "MIT" }, "node_modules/tiny-invariant": { @@ -23151,9 +23679,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -23193,6 +23721,7 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -23209,6 +23738,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -23221,6 +23751,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -23281,6 +23812,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, "license": "MIT" }, "node_modules/trim-lines": { @@ -23454,6 +23986,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -23466,6 +23999,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, "license": "MIT", "dependencies": { "media-typer": "0.3.0", @@ -23595,6 +24129,7 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { @@ -23643,6 +24178,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -23793,6 +24329,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -23802,6 +24339,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -23846,6 +24384,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -23901,6 +24440,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -23941,6 +24481,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -23951,6 +24492,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -23960,6 +24502,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -24026,6 +24569,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, "license": "MIT", "dependencies": { "defaults": "^1.0.3" @@ -24041,6 +24585,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/wgsl_reflect": { @@ -24090,6 +24635,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -24100,6 +24646,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^3.1.1" @@ -24189,6 +24736,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-2.2.0.tgz", "integrity": "sha512-MOiaDbA5ZZgUjkeMWM5EkJp4loW5ZRoa5bc3/aeMox/PJelMhE6t7S/mLuiY43DBupyxH+S0U1bTui9kWUlmsw==", + "dev": true, "license": "MIT", "dependencies": { "load-yaml-file": "^0.2.0", @@ -24224,6 +24772,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, "license": "MIT", "dependencies": { "string-width": "^4.0.0" @@ -24246,12 +24795,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, "license": "MIT" }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -24270,6 +24821,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -24287,12 +24839,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -24306,6 +24860,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -24318,6 +24873,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -24387,9 +24943,9 @@ } }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -24428,6 +24984,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 4942854..6dcb694 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,7 @@ "@mui/x-charts": "^7.29.1", "@mui/x-data-grid": "^7.22.2", "@mui/x-date-pickers": "^8.12.0", - "@refinedev/cli": "^2.16.50", "@refinedev/core": "^5.0.8", - "@refinedev/devtools": "^2.0.3", "@refinedev/kbar": "^2.0.1", "@refinedev/mui": "^8.0.0", "@refinedev/nextjs-router": "^7.0.4", @@ -59,6 +57,12 @@ "fast-xml-parser": "5.5.9" }, "devDependencies": { + "@refinedev/cli": "^2.16.52", + "@refinedev/devtools": "^2.0.5", + "@refinedev/devtools-internal": "^2.0.2", + "@refinedev/devtools-server": "^2.0.2", + "@refinedev/devtools-shared": "^2.0.2", + "@refinedev/devtools-ui": "^2.0.3", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", -- 2.54.0 From 781711943aaded9ce73dda3fff247b0e10526afc Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 7 Apr 2026 09:45:15 +0800 Subject: [PATCH 073/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20NEXT=5FPUBLIC=5FCO?= =?UTF-8?q?PILOT=5FURL=20=E5=8F=98=E9=87=8F=E5=88=B0=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 1 + 1 file changed, 1 insertion(+) diff --git a/.env b/.env index 4ed338d..def8707 100644 --- a/.env +++ b/.env @@ -6,6 +6,7 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/" # 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn" +NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn" NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" NEXT_PUBLIC_MAP_WORKSPACE="tjwater" NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" -- 2.54.0 From b752be498a734557b29453eb44ccb092bcce0971 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 7 Apr 2026 09:46:22 +0800 Subject: [PATCH 074/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20@refinedev=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=E8=87=B3?= =?UTF-8?q?=E6=9C=80=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 67 +++++++++-------------------------------------- package.json | 6 ++--- 2 files changed, 16 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8810eb..d50a707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,10 @@ "@mui/x-charts": "^7.29.1", "@mui/x-data-grid": "^7.22.2", "@mui/x-date-pickers": "^8.12.0", - "@refinedev/core": "^5.0.8", + "@refinedev/core": "^5.0.12", "@refinedev/kbar": "^2.0.1", - "@refinedev/mui": "^8.0.0", - "@refinedev/nextjs-router": "^7.0.4", + "@refinedev/mui": "^8.0.2", + "@refinedev/nextjs-router": "^7.0.5", "@refinedev/react-hook-form": "^5.0.4", "@refinedev/simple-rest": "^6.0.1", "@tailwindcss/postcss": "^4.1.13", @@ -6055,12 +6055,12 @@ } }, "node_modules/@refinedev/core": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.8.tgz", - "integrity": "sha512-zve38YbhR4vV6fWXpJ6vJVd4fgXuYX0INqO/ELxZjf6dMgakEiob4wZ4LWN21hxpPWNysKoBIMR38t3GfSgZLA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz", + "integrity": "sha512-9y5Bi9Lb7XyJmM55b8rCeBTDRCBU41p47OymJldasLfrtpUm2EwI+27DjjNpHTOugymiZsIbLlPtHCPQIXBHcg==", "license": "MIT", "dependencies": { - "@refinedev/devtools-internal": "2.0.1", + "@refinedev/devtools-internal": "2.0.2", "@tanstack/react-query": "^5.81.5", "lodash": "^4.17.21", "lodash-es": "^4.17.21", @@ -6081,45 +6081,6 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@refinedev/core/node_modules/@refinedev/devtools-internal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@refinedev/devtools-internal/-/devtools-internal-2.0.1.tgz", - "integrity": "sha512-B28TrwJoQ+afm2jC74r96jgaAQXhc4SHYnpenJSyMrv0nxL3Trnr+QnuRxSIjOaYm7y9pACpX8lqbzIouWPCfg==", - "license": "MIT", - "dependencies": { - "@refinedev/devtools-shared": "2.0.1", - "@tanstack/react-query": "^5.81.5", - "error-stack-parser": "^2.1.4" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@refinedev/core/node_modules/@refinedev/devtools-shared": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.1.tgz", - "integrity": "sha512-x9Eg7wdwx8AB9LgN9Gg/bW4icpdIYCoJLCXZA2kOoY77+CHmUY6Uv78RgimvkIGEHZucbchJLCFoLdZkvx5ceQ==", - "license": "MIT", - "dependencies": { - "@tanstack/react-query": "^5.81.5", - "error-stack-parser": "^2.1.4" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, "node_modules/@refinedev/devtools": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@refinedev/devtools/-/devtools-2.0.5.tgz", @@ -6150,7 +6111,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@refinedev/devtools-internal/-/devtools-internal-2.0.2.tgz", "integrity": "sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg==", - "dev": true, "license": "MIT", "dependencies": { "@refinedev/devtools-shared": "2.0.2", @@ -6215,7 +6175,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.2.tgz", "integrity": "sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA==", - "dev": true, "license": "MIT", "dependencies": { "@tanstack/react-query": "^5.81.5", @@ -6290,9 +6249,9 @@ } }, "node_modules/@refinedev/mui": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@refinedev/mui/-/mui-8.0.0.tgz", - "integrity": "sha512-qXcCPgRqD88JhlEhIAeru1LAUHUeprYr2D4GB6eGrPoj7bCYSBmUOZMj24pvdhekF6i9WHj60S+rU3IAPDfAEA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/mui/-/mui-8.0.2.tgz", + "integrity": "sha512-YGs2wa9xKPNlbPHRvMZDhCgatbH/Ibhgxe+qJXzrrNC2s5NwBbirrb/o+ge2BB1i7CaKL7jSSTAEgCJGVC0kPw==", "license": "MIT", "dependencies": { "@emotion/react": "^11.8.2", @@ -7118,9 +7077,9 @@ } }, "node_modules/@refinedev/nextjs-router": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@refinedev/nextjs-router/-/nextjs-router-7.0.4.tgz", - "integrity": "sha512-r7K/PwvwUzq8ZvRpGE5hqTiDgKIoAns+lLUiZBHfNRE3yOfZtUySM0IaXrhviikdaH/tBWa6wrMiTg9fxiUdlw==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@refinedev/nextjs-router/-/nextjs-router-7.0.5.tgz", + "integrity": "sha512-Z724KBsnEtESGYZMntXEhXr9gmQD/kD6s7poeMY4HeLtWLfNyJPdopHntD4BYMU1ApZweDBJeSqEuWjoL3/x5A==", "license": "MIT", "dependencies": { "qs": "^6.10.1", diff --git a/package.json b/package.json index 6dcb694..fc38b50 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "@mui/x-charts": "^7.29.1", "@mui/x-data-grid": "^7.22.2", "@mui/x-date-pickers": "^8.12.0", - "@refinedev/core": "^5.0.8", + "@refinedev/core": "^5.0.12", "@refinedev/kbar": "^2.0.1", - "@refinedev/mui": "^8.0.0", - "@refinedev/nextjs-router": "^7.0.4", + "@refinedev/mui": "^8.0.2", + "@refinedev/nextjs-router": "^7.0.5", "@refinedev/react-hook-form": "^5.0.4", "@refinedev/simple-rest": "^6.0.1", "@tailwindcss/postcss": "^4.1.13", -- 2.54.0 From 5dab6464c3fae6644256125de0212a0b9f5556cd Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 7 Apr 2026 10:08:00 +0800 Subject: [PATCH 075/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20React=20=E5=92=8C?= =?UTF-8?q?=20React-DOM=20=E7=89=88=E6=9C=AC=E8=87=B3=2019.2.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 26 +++++++++++++------------- package.json | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index d50a707..ae9fa19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,8 +35,8 @@ "next-auth": "^4.24.5", "ol": "^10.7.0", "postcss": "^8.5.6", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-draggable": "^4.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", @@ -21580,24 +21580,24 @@ } }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.4" } }, "node_modules/react-draggable": { @@ -22414,9 +22414,9 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/section-matter": { diff --git a/package.json b/package.json index fc38b50..38f8c4b 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "next-auth": "^4.24.5", "ol": "^10.7.0", "postcss": "^8.5.6", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-draggable": "^4.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", -- 2.54.0 From bfa4020239bad3d6fcff831853c4b848f31d5641 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 15 Apr 2026 11:52:40 +0800 Subject: [PATCH 076/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20TypeScript=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E7=9B=AE=E6=A0=87=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20esnext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index f1900ff..868d7b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "esnext", "lib": [ "dom", "dom.iterable", @@ -13,7 +13,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", -- 2.54.0 From 259202ca8f914705883e00e47eb1ffc3e0b25a7d Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 15 Apr 2026 17:40:30 +0800 Subject: [PATCH 077/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=20URL=20=E9=85=8D=E7=BD=AE=E5=88=B0=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=EF=BC=9B=E4=BD=BF=E7=94=A8=E6=96=B0?= =?UTF-8?q?=E7=9A=84=20TTS=20=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 +- src/components/chat/GlobalChatbox.voice.ts | 563 +++++++++++++++++++-- src/config/config.ts | 2 + 3 files changed, 530 insertions(+), 38 deletions(-) diff --git a/.env b/.env index def8707..1a8f4e1 100644 --- a/.env +++ b/.env @@ -7,10 +7,11 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/" # 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn" NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn" +NEXT_PUBLIC_AUDIO_SERVICE_URL="http://127.0.0.1:18083" NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" NEXT_PUBLIC_MAP_WORKSPACE="tjwater" NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" # NEXT_PUBLIC_MAP_AVAILABLE_LAYERS="junctions, pipes, reservoirs, scada" NEXT_PUBLIC_NETWORK_NAME="tjwater" NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg" -NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec" \ No newline at end of file +NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec" diff --git a/src/components/chat/GlobalChatbox.voice.ts b/src/components/chat/GlobalChatbox.voice.ts index cce50d9..2524c2b 100644 --- a/src/components/chat/GlobalChatbox.voice.ts +++ b/src/components/chat/GlobalChatbox.voice.ts @@ -1,6 +1,31 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import config from "@/config/config"; import type { SpeechState } from "./GlobalChatbox.types"; +type AudioStreamStartResponse = { + stream_id?: string; + audio_url?: string; + status_url?: string; + result_url?: string; + sample_rate?: number; + channels?: number; + error?: string; +}; + +type AudioStreamStatusResponse = { + state?: "starting" | "running" | "done" | "failed" | "closed"; + ready?: boolean; + failed?: boolean; + closed?: boolean; + status_text?: string; + error?: string; +}; + +type AudioStreamResultResponse = { + run_status?: string; + error?: string; +}; + // WebKit Speech Recognition compatibility interface SpeechRecognitionEvent extends Event { readonly resultIndex: number; @@ -29,70 +54,534 @@ declare global { new (): SpeechRecognition; prototype: SpeechRecognition; }; + webkitAudioContext?: typeof AudioContext; } } export function useSpeechSynthesis() { const [speechState, setSpeechState] = useState<SpeechState>("idle"); const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null); - const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null); + const audioContextRef = useRef<AudioContext | null>(null); + const streamAbortControllerRef = useRef<AbortController | null>(null); + const activeSourceNodesRef = useRef<Set<AudioBufferSourceNode>>(new Set()); + const streamIdRef = useRef<string | null>(null); + const closeUrlRef = useRef<string | null>(null); + const statusUrlRef = useRef<string | null>(null); + const resultUrlRef = useRef<string | null>(null); + const statusPollTimeoutRef = useRef<number | null>(null); + const playbackTokenRef = useRef(0); - const isSupported = typeof window !== "undefined" && "speechSynthesis" in window; + const isSupported = + typeof window !== "undefined" && + typeof window.FormData !== "undefined" && + (typeof window.AudioContext !== "undefined" || + typeof window.webkitAudioContext !== "undefined"); - const stop = useCallback(() => { - if (!isSupported) return; - window.speechSynthesis.cancel(); - utteranceRef.current = null; + const trimTrailingSlash = useCallback((value: string) => value.replace(/\/+$/, ""), []); + + const buildServiceUrl = useCallback( + (path: string) => `${trimTrailingSlash(config.AUDIO_SERVICE_URL)}${path.startsWith("/") ? path : `/${path}`}`, + [trimTrailingSlash], + ); + + const resolveServiceUrl = useCallback( + (pathOrUrl: string) => { + if (/^https?:\/\//i.test(pathOrUrl)) { + return pathOrUrl; + } + return buildServiceUrl(pathOrUrl); + }, + [buildServiceUrl], + ); + + const withQueryParams = useCallback( + (urlString: string, params: Record<string, string>) => { + const url = new URL(urlString); + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + return url.toString(); + }, + [], + ); + + const readErrorMessage = useCallback(async (response: Response, fallback: string) => { + try { + const payload = (await response.json()) as { error?: string; message?: string }; + return payload.error || payload.message || fallback; + } catch { + return fallback; + } + }, []); + + const closeStream = useCallback(async (closeUrl: string) => { + const response = await fetch(closeUrl, { + method: "POST", + }); + + if (!response.ok) { + console.error("[GlobalChatbox] Failed to close audio stream:", closeUrl); + } + }, []); + + const stopStatusPolling = useCallback(() => { + if (statusPollTimeoutRef.current !== null) { + window.clearTimeout(statusPollTimeoutRef.current); + statusPollTimeoutRef.current = null; + } + }, []); + + const fetchStreamResult = useCallback( + async (resultUrl: string) => { + const response = await fetch(resultUrl); + if (response.status === 202) { + return false; + } + if (!response.ok) { + throw new Error( + await readErrorMessage( + response, + `Audio stream result failed with status ${response.status}`, + ), + ); + } + + const payload = (await response.json()) as AudioStreamResultResponse; + if (payload.error) { + throw new Error(payload.error); + } + + return true; + }, + [readErrorMessage], + ); + + const clearAudio = useCallback(async () => { + const abortController = streamAbortControllerRef.current; + streamAbortControllerRef.current = null; + abortController?.abort(); + + activeSourceNodesRef.current.forEach((source) => { + try { + source.onended = null; + source.stop(); + } catch { + // ignore stop errors when source already ended + } + source.disconnect(); + }); + activeSourceNodesRef.current.clear(); + + const audioContext = audioContextRef.current; + audioContextRef.current = null; + if (!audioContext) return; + + try { + await audioContext.close(); + } catch { + // ignore close errors when context already closed + } + }, []); + + const playPcmStream = useCallback( + async ({ + audioUrl, + sampleRate, + channels, + playbackToken, + }: { + audioUrl: string; + sampleRate: number; + channels: number; + playbackToken: number; + }) => { + const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext; + if (!AudioContextCtor) { + throw new Error("WebAudio AudioContext is not available in this browser"); + } + + const abortController = new AbortController(); + streamAbortControllerRef.current = abortController; + + const response = await fetch(withQueryParams(audioUrl, { format: "pcm" }), { + signal: abortController.signal, + }); + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Audio stream failed with status ${response.status}`), + ); + } + if (!response.body) { + throw new Error("Audio stream response body is missing"); + } + + const audioContext = new AudioContextCtor({ + sampleRate, + }); + audioContextRef.current = audioContext; + + const reader = response.body.getReader(); + const bytesPerFrame = Math.max(1, channels) * 2; + let bufferedRemainder = new Uint8Array(0); + let nextStartTime = audioContext.currentTime + 0.05; + let activeSources = 0; + let streamEnded = false; + let resolvePlaybackDrain: (() => void) | null = null; + const playbackDrainPromise = new Promise<void>((resolve) => { + resolvePlaybackDrain = resolve; + }); + + const maybeResolvePlaybackDrain = () => { + if (streamEnded && activeSources === 0) { + resolvePlaybackDrain?.(); + } + }; + + const schedulePcmChunk = (pcmBytes: Uint8Array) => { + const frameCount = pcmBytes.byteLength / bytesPerFrame; + if (frameCount <= 0) return; + + const buffer = audioContext.createBuffer(Math.max(1, channels), frameCount, sampleRate); + const view = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength); + for (let frame = 0; frame < frameCount; frame += 1) { + for (let channel = 0; channel < Math.max(1, channels); channel += 1) { + const sampleIndex = frame * Math.max(1, channels) + channel; + const pcm = view.getInt16(sampleIndex * 2, true); + buffer.getChannelData(channel)[frame] = pcm / 32768; + } + } + + const source = audioContext.createBufferSource(); + source.buffer = buffer; + source.connect(audioContext.destination); + const sourceStartTime = Math.max(nextStartTime, audioContext.currentTime + 0.01); + nextStartTime = sourceStartTime + buffer.duration; + + activeSources += 1; + activeSourceNodesRef.current.add(source); + source.onended = () => { + activeSources -= 1; + activeSourceNodesRef.current.delete(source); + source.disconnect(); + maybeResolvePlaybackDrain(); + }; + source.start(sourceStartTime); + }; + + const concatUint8Arrays = (a: Uint8Array, b: Uint8Array) => { + if (a.byteLength === 0) return b; + if (b.byteLength === 0) return a; + const merged = new Uint8Array(a.byteLength + b.byteLength); + merged.set(a); + merged.set(b, a.byteLength); + return merged; + }; + + while (true) { + if (playbackToken !== playbackTokenRef.current) { + throw new DOMException("PCM stream playback cancelled", "AbortError"); + } + + const { done, value } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + + const merged = concatUint8Arrays(bufferedRemainder, value); + const alignedByteLength = merged.byteLength - (merged.byteLength % bytesPerFrame); + if (alignedByteLength === 0) { + bufferedRemainder = new Uint8Array(merged); + continue; + } + + const alignedChunk = merged.slice(0, alignedByteLength); + bufferedRemainder = new Uint8Array(merged.slice(alignedByteLength)); + schedulePcmChunk(alignedChunk); + } + + streamEnded = true; + maybeResolvePlaybackDrain(); + await playbackDrainPromise; + }, + [readErrorMessage, withQueryParams], + ); + + const stopPlayback = useCallback(async () => { + await clearAudio(); + stopStatusPolling(); + + const closeUrl = closeUrlRef.current; + streamIdRef.current = null; + closeUrlRef.current = null; + statusUrlRef.current = null; + resultUrlRef.current = null; setSpeechState("idle"); setSpeakingMessageId(null); - }, [isSupported]); + + if (closeUrl) { + try { + await closeStream(closeUrl); + } catch (error) { + console.error("[GlobalChatbox] Failed to close audio stream:", error); + } + } + }, [clearAudio, closeStream, stopStatusPolling]); + + const pollStreamStatus = useCallback( + (playbackToken: number, statusUrl: string, resultUrl: string) => { + stopStatusPolling(); + + statusPollTimeoutRef.current = window.setTimeout(async () => { + if ( + playbackToken !== playbackTokenRef.current || + statusUrlRef.current !== statusUrl || + resultUrlRef.current !== resultUrl + ) { + return; + } + + try { + const response = await fetch(statusUrl); + if (!response.ok) { + throw new Error( + await readErrorMessage( + response, + `Audio stream status failed with status ${response.status}`, + ), + ); + } + + const payload = (await response.json()) as AudioStreamStatusResponse; + if ( + playbackToken !== playbackTokenRef.current || + statusUrlRef.current !== statusUrl || + resultUrlRef.current !== resultUrl + ) { + return; + } + + if (payload.failed || payload.state === "failed") { + console.error( + "[GlobalChatbox] Audio stream failed:", + payload.error || payload.status_text || statusUrl, + ); + playbackTokenRef.current += 1; + void stopPlayback(); + return; + } + + if (payload.closed || payload.state === "closed") { + stopStatusPolling(); + return; + } + + if (payload.ready || payload.state === "done") { + try { + const isResultReady = await fetchStreamResult(resultUrl); + if (isResultReady) { + stopStatusPolling(); + return; + } + } catch (error) { + console.error("[GlobalChatbox] Failed to fetch audio stream result:", error); + } + } + + pollStreamStatus(playbackToken, statusUrl, resultUrl); + } catch (error) { + if ( + playbackToken === playbackTokenRef.current && + statusUrlRef.current === statusUrl && + resultUrlRef.current === resultUrl + ) { + console.error("[GlobalChatbox] Failed to poll audio stream status:", error); + pollStreamStatus(playbackToken, statusUrl, resultUrl); + } + } + }, 1000); + }, + [fetchStreamResult, readErrorMessage, stopPlayback, stopStatusPolling], + ); + + const stop = useCallback(() => { + playbackTokenRef.current += 1; + void stopPlayback(); + }, [stopPlayback]); const speak = useCallback( - (messageId: string, text: string) => { - if (!isSupported || !text) return; - window.speechSynthesis.cancel(); + async (messageId: string, text: string) => { + const normalizedText = text.trim(); + if (!isSupported || !normalizedText) return; - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = "zh-CN"; - utterance.rate = 1; - utterance.onend = () => { - setSpeechState("idle"); - setSpeakingMessageId(null); - utteranceRef.current = null; - }; - utterance.onerror = () => { - setSpeechState("idle"); - setSpeakingMessageId(null); - utteranceRef.current = null; - }; - utterance.onpause = () => setSpeechState("paused"); - utterance.onresume = () => setSpeechState("playing"); + const playbackToken = playbackTokenRef.current + 1; + playbackTokenRef.current = playbackToken; + await stopPlayback(); - utteranceRef.current = utterance; setSpeakingMessageId(messageId); setSpeechState("playing"); - window.speechSynthesis.speak(utterance); + + try { + const formData = new FormData(); + formData.append("text", normalizedText); + formData.append("demo_id", "demo-1"); + + const response = await fetch(buildServiceUrl("/api/generate-stream/start"), { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error( + await readErrorMessage( + response, + `Audio stream start failed with status ${response.status}`, + ), + ); + } + + const payload = (await response.json()) as AudioStreamStartResponse; + const streamId = payload.stream_id; + const sampleRate = + typeof payload.sample_rate === "number" && payload.sample_rate > 0 + ? payload.sample_rate + : 24000; + const channels = + typeof payload.channels === "number" && payload.channels > 0 + ? payload.channels + : 1; + const audioUrl = payload.audio_url + ? resolveServiceUrl(payload.audio_url) + : buildServiceUrl( + `/api/generate-stream/${encodeURIComponent(streamId ?? "")}/audio?format=pcm`, + ); + const rawStatusUrl = payload.status_url + ? resolveServiceUrl(payload.status_url) + : buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/status`); + const statusUrl = withQueryParams(rawStatusUrl, { compact: "1" }); + const rawResultUrl = payload.result_url + ? resolveServiceUrl(payload.result_url) + : buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/result`); + const resultUrl = withQueryParams(rawResultUrl, { + compact: "1", + include_audio: "0", + }); + const closeUrl = buildServiceUrl( + `/api/generate-stream/${encodeURIComponent(streamId ?? "")}/close`, + ); + + if (!streamId) { + throw new Error(payload.error || "Audio stream start response is missing stream_id"); + } + + if (playbackToken !== playbackTokenRef.current) { + await closeStream(closeUrl); + return; + } + + streamIdRef.current = streamId; + closeUrlRef.current = closeUrl; + statusUrlRef.current = statusUrl; + resultUrlRef.current = resultUrl; + + pollStreamStatus(playbackToken, statusUrl, resultUrl); + await playPcmStream({ + audioUrl, + sampleRate, + channels, + playbackToken, + }); + + if (playbackToken !== playbackTokenRef.current) { + return; + } + + await clearAudio(); + if (streamIdRef.current === streamId) { + streamIdRef.current = null; + closeUrlRef.current = null; + statusUrlRef.current = null; + resultUrlRef.current = null; + setSpeechState("idle"); + setSpeakingMessageId(null); + } + stopStatusPolling(); + await fetchStreamResult(resultUrl).catch((error) => { + console.error("[GlobalChatbox] Failed to fetch audio stream result:", error); + }); + await closeStream(closeUrl); + } catch (error) { + await clearAudio(); + if ( + error instanceof DOMException && + error.name === "AbortError" && + playbackToken !== playbackTokenRef.current + ) { + return; + } + const closeUrl = closeUrlRef.current; + streamIdRef.current = null; + closeUrlRef.current = null; + statusUrlRef.current = null; + resultUrlRef.current = null; + setSpeechState("idle"); + setSpeakingMessageId(null); + if (closeUrl) { + try { + await closeStream(closeUrl); + } catch (closeError) { + console.error("[GlobalChatbox] Failed to close audio stream:", closeError); + } + } + console.error("[GlobalChatbox] Failed to play audio stream:", error); + } }, - [isSupported], + [ + buildServiceUrl, + clearAudio, + closeStream, + fetchStreamResult, + isSupported, + playPcmStream, + readErrorMessage, + resolveServiceUrl, + pollStreamStatus, + stopPlayback, + stopStatusPolling, + withQueryParams, + ], ); const pause = useCallback(() => { - if (!isSupported) return; - window.speechSynthesis.pause(); + if (!isSupported || !audioContextRef.current) return; + void audioContextRef.current.suspend().then( + () => { + setSpeechState("paused"); + }, + (error) => { + console.error("[GlobalChatbox] Failed to pause PCM playback:", error); + }, + ); }, [isSupported]); const resume = useCallback(() => { - if (!isSupported) return; - window.speechSynthesis.resume(); - }, [isSupported]); + if (!isSupported || !audioContextRef.current) return; + void audioContextRef.current.resume().then( + () => { + setSpeechState("playing"); + }, + (error) => { + playbackTokenRef.current += 1; + void stopPlayback(); + console.error("[GlobalChatbox] Failed to resume audio playback:", error); + }, + ); + }, [isSupported, stopPlayback]); useEffect(() => { return () => { - if (typeof window !== "undefined" && "speechSynthesis" in window) { - window.speechSynthesis.cancel(); - } + playbackTokenRef.current += 1; + void stopPlayback(); }; - }, []); + }, [stopPlayback]); return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported }; } diff --git a/src/config/config.ts b/src/config/config.ts index ca689f2..9a9a256 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,6 +1,8 @@ export const config = { BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787", + AUDIO_SERVICE_URL: + process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083", MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater", MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT -- 2.54.0 From 5cbf1e82f8f11efbd1194450c5fa261c392b99d2 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 15 Apr 2026 17:40:45 +0800 Subject: [PATCH 078/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=88=86=E7=AE=A1?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E5=92=8C=E7=88=86=E7=AE=A1=E4=BE=A6=E6=B5=8B?= =?UTF-8?q?=E7=9A=84=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/_refine_context.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index b7f1441..9eece2b 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -182,15 +182,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => { label: "爆管模拟", }, }, - { - name: "爆管定位", - list: "/hydraulic-simulation/burst-location", - meta: { - parent: "Hydraulic Simulation", - icon: <MyLocationIcon className="w-6 h-6" />, - label: "爆管定位", - }, - }, { name: "爆管侦测", list: "/hydraulic-simulation/burst-detection", @@ -200,6 +191,15 @@ const App = (props: React.PropsWithChildren<AppProps>) => { label: "爆管侦测", }, }, + { + name: "爆管定位", + list: "/hydraulic-simulation/burst-location", + meta: { + parent: "Hydraulic Simulation", + icon: <MyLocationIcon className="w-6 h-6" />, + label: "爆管定位", + }, + }, { name: "DMA 漏损识别", list: "/hydraulic-simulation/dma-leak-detection", -- 2.54.0 From ff5cbfde9cdee5bcd63b14436f55b028e0dcba7c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 15 Apr 2026 17:40:52 +0800 Subject: [PATCH 079/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E4=B8=BA=20ISO=20=E6=A0=BC=E5=BC=8F=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20API=20=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index b9e07e8..9dbeb1b 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -225,7 +225,7 @@ const AnalysisParameters: React.FC = () => { setAnalyzing(true); try { - const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00"); + const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00 const params = { scheme_name: schemeName, @@ -242,7 +242,7 @@ const AnalysisParameters: React.FC = () => { // but axios usually handles array as valves[]=1&valves[]=2 // FastAPI default expects repeated query params. - const response = await api.get(`${config.BACKEND_URL}/flushing_analysis/`, { + const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing_analysis/`, { params, // Ensure arrays are sent as repeated keys: valves=1&valves=2 paramsSerializer: { -- 2.54.0 From 6410df0cb7986b486f8534ff27e78084fbf9d655 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 15 Apr 2026 18:42:50 +0800 Subject: [PATCH 080/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=B9=E6=A1=88?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=BC=93=E5=AD=98=E6=94=AF=E6=8C=81=E5=88=B0?= =?UTF-8?q?=E7=88=86=E7=AE=A1=E5=92=8C=E6=BC=8F=E6=8D=9F=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BurstDetection/BurstDetectionPanel.tsx | 5 +++-- .../olmap/BurstDetection/SchemeQuery.tsx | 13 ++++++++---- .../BurstLocation/BurstLocationPanel.tsx | 5 +++-- .../olmap/BurstLocation/SchemeQuery.tsx | 13 ++++++++---- .../olmap/BurstSimulation/SchemeQuery.tsx | 21 +++++++++---------- .../ContaminantSimulation/SchemeQuery.tsx | 21 +++++++++---------- .../WaterQualityPanel.tsx | 8 ++++++- .../DMALeakDetectionPanel.tsx | 5 +++-- .../olmap/DMALeakDetection/SchemeQuery.tsx | 11 +++++++--- .../FlushingAnalysisPanel.tsx | 4 +++- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 21 +++++++++---------- .../SchemeQuery.tsx | 21 +++++++++---------- 12 files changed, 85 insertions(+), 63 deletions(-) diff --git a/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx index d85182b..54a6f21 100644 --- a/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx +++ b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx @@ -12,7 +12,7 @@ import { import AnalysisParameters from "./AnalysisParameters"; import DetectionResults from "./DetectionResults"; import SchemeQuery from "./SchemeQuery"; -import { BurstDetectionResult } from "./types"; +import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types"; const TabPanel = ({ value, @@ -32,6 +32,7 @@ const BurstDetectionPanel: React.FC = () => { const [open, setOpen] = useState(true); const [tab, setTab] = useState(0); const [result, setResult] = useState<BurstDetectionResult | null>(null); + const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]); const drawerWidth = 450; const panelTitle = "爆管侦测"; @@ -139,7 +140,7 @@ const BurstDetectionPanel: React.FC = () => { <AnalysisParameters onResult={handleResult} /> </TabPanel> <TabPanel value={tab} index={1}> - <SchemeQuery onViewResult={handleResult} /> + <SchemeQuery onViewResult={handleResult} schemes={schemes} onSchemesChange={setSchemes} /> </TabPanel> <TabPanel value={tab} index={2}> <DetectionResults result={result} /> diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx index a00eaec..3d292e2 100644 --- a/src/components/olmap/BurstDetection/SchemeQuery.tsx +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -31,15 +31,19 @@ import { interface Props { onViewResult: (result: BurstDetectionResult) => void; + schemes?: BurstDetectionSchemeRecord[]; + onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void; } -const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { +const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => { const { open } = useNotification(); const [queryAll, setQueryAll] = useState(true); const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); - const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]); + const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]); const [loading, setLoading] = useState(false); const [expandedId, setExpandedId] = useState<number | null>(null); + const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; + const setSchemes = onSchemesChange || setInternalSchemes; const buildDisplayResult = ( scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">, @@ -88,11 +92,12 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { } const response = await api.get("/api/v1/burst-detection/schemes/", { params }); - setSchemes(response.data); + const nextSchemes = response.data as BurstDetectionSchemeRecord[]; + setSchemes(nextSchemes); open?.({ type: "success", message: "查询成功", - description: `共找到 ${response.data.length} 条侦测记录。`, + description: `共找到 ${nextSchemes.length} 条侦测记录。`, }); } catch (error: any) { open?.({ diff --git a/src/components/olmap/BurstLocation/BurstLocationPanel.tsx b/src/components/olmap/BurstLocation/BurstLocationPanel.tsx index fe56b8f..9643d0f 100644 --- a/src/components/olmap/BurstLocation/BurstLocationPanel.tsx +++ b/src/components/olmap/BurstLocation/BurstLocationPanel.tsx @@ -12,7 +12,7 @@ import { import AnalysisParameters from "./AnalysisParameters"; import LocationResults from "./LocationResults"; import SchemeQuery from "./SchemeQuery"; -import { BurstLocationResult } from "./types"; +import { BurstLocationResult, BurstSchemeRecord } from "./types"; const TabPanel = ({ value, @@ -32,6 +32,7 @@ const BurstLocationPanel: React.FC = () => { const [open, setOpen] = useState(true); const [tab, setTab] = useState(0); const [result, setResult] = useState<BurstLocationResult | null>(null); + const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]); const drawerWidth = 450; const panelTitle = "爆管定位"; @@ -148,7 +149,7 @@ const BurstLocationPanel: React.FC = () => { <AnalysisParameters onResult={handleResult} /> </TabPanel> <TabPanel value={tab} index={1}> - <SchemeQuery onViewResult={handleViewResult} /> + <SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} /> </TabPanel> <TabPanel value={tab} index={2}> <LocationResults result={result} /> diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index 0b28bc7..bd3a100 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -32,15 +32,19 @@ import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; interface Props { onViewResult: (result: BurstLocationResult) => void; + schemes?: BurstSchemeRecord[]; + onSchemesChange?: (schemes: BurstSchemeRecord[]) => void; } -const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { +const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => { const { open } = useNotification(); const [queryAll, setQueryAll] = useState(true); const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); - const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]); + const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [loading, setLoading] = useState(false); const [expandedId, setExpandedId] = useState<number | null>(null); + const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; + const setSchemes = onSchemesChange || setInternalSchemes; const buildDisplayResult = ( scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">, @@ -87,11 +91,12 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { } const response = await api.get(url, { params }); - setSchemes(response.data); + const nextSchemes = response.data as BurstSchemeRecord[]; + setSchemes(nextSchemes); open?.({ type: "success", message: "查询成功", - description: `共找到 ${response.data.length} 条记录`, + description: `共找到 ${nextSchemes.length} 条记录`, }); } catch (error: any) { console.error(error); diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index 65fce5c..22915e6 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -122,17 +122,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ }); } - setSchemes( - filteredResults.map((item: SchemaItem) => ({ - id: item.scheme_id, - schemeName: item.scheme_name, - type: item.scheme_type, - user: item.username, - create_time: item.create_time, - startTime: item.scheme_start_time, - schemeDetail: item.scheme_detail, - })), - ); + const nextSchemes = filteredResults.map((item: SchemaItem) => ({ + id: item.scheme_id, + schemeName: item.scheme_name, + type: item.scheme_type, + user: item.username, + create_time: item.create_time, + startTime: item.scheme_start_time, + schemeDetail: item.scheme_detail, + })); + setSchemes(nextSchemes); if (filteredResults.length === 0) { open?.({ diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index ccef88d..faec59a 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -195,17 +195,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ ); } - setSchemes( - filteredResults.map((item: ContaminantSchemaItem) => ({ - id: item.scheme_id, - schemeName: item.scheme_name, - type: item.scheme_type, - user: item.username, - create_time: item.create_time, - startTime: item.scheme_start_time, - schemeDetail: item.scheme_detail, - })), - ); + const nextSchemes = filteredResults.map((item: ContaminantSchemaItem) => ({ + id: item.scheme_id, + schemeName: item.scheme_name, + type: item.scheme_type, + user: item.username, + create_time: item.create_time, + startTime: item.scheme_start_time, + schemeDetail: item.scheme_detail, + })); + setSchemes(nextSchemes); if (filteredResults.length === 0) { open?.({ diff --git a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx index d7ba9e4..e7709ac 100644 --- a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx +++ b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx @@ -20,6 +20,7 @@ import { import ContaminantAnalysisParameters from "./AnalysisParameters"; import ContaminantSchemeQuery from "./SchemeQuery"; import { useData } from "@components/olmap/core/MapComponent"; +import { ContaminantSchemeRecord } from "./types"; interface WaterQualityPanelProps { open?: boolean; @@ -32,6 +33,7 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({ }) => { const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); + const [schemes, setSchemes] = useState<ContaminantSchemeRecord[]>([]); const data = useData(); @@ -172,7 +174,11 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({ </TabPanel> <TabPanel value={currentTab} index={1}> - <ContaminantSchemeQuery onViewResults={() => setCurrentTab(2)} /> + <ContaminantSchemeQuery + schemes={schemes} + onSchemesChange={setSchemes} + onViewResults={() => setCurrentTab(2)} + /> </TabPanel> </Box> </Drawer> diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index ec36d49..40d4c69 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -27,7 +27,7 @@ import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; import RecognitionResults from "./RecognitionResults"; import { getAreaColor } from "./utils"; -import { LeakageResultDetail } from "./types"; +import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; import { config } from "@/config/config"; const TabPanel = ({ @@ -52,6 +52,7 @@ const DMALeakDetectionPanel: React.FC = () => { const [tab, setTab] = useState(0); const [result, setResult] = useState<LeakageResultDetail | null>(null); const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null); + const [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]); const drawerWidth = 450; const panelTitle = "DMA 漏损识别"; @@ -277,7 +278,7 @@ const DMALeakDetectionPanel: React.FC = () => { <AnalysisParameters onResult={handleAnalysisResult} /> </TabPanel> <TabPanel value={tab} index={1}> - <SchemeQuery onViewResult={handleViewResult} /> + <SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} /> </TabPanel> <TabPanel value={tab} index={2}> <RecognitionResults result={result} /> diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index 38fb60b..cd709af 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -28,15 +28,19 @@ import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; interface Props { onViewResult: (result: LeakageResultDetail) => void; + schemes?: LeakageSchemeRecord[]; + onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void; } -const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { +const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => { const { open } = useNotification(); const [queryAll, setQueryAll] = useState(true); const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); - const [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]); + const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]); const [loading, setLoading] = useState(false); const [expandedId, setExpandedId] = useState<number | null>(null); + const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; + const setSchemes = onSchemesChange || setInternalSchemes; const handleQuery = async () => { setLoading(true); @@ -48,7 +52,8 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult }) => { const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, { params, }); - setSchemes(response.data); + const nextSchemes = response.data as LeakageSchemeRecord[]; + setSchemes(nextSchemes); } catch (error: any) { open?.({ type: "error", diff --git a/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx b/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx index 83523bc..c1bf39a 100644 --- a/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx +++ b/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx @@ -19,6 +19,7 @@ import { import { MdCleaningServices } from "react-icons/md"; import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; +import { SchemeRecord } from "./types"; interface TabPanelProps { children?: React.ReactNode; @@ -51,6 +52,7 @@ const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({ }) => { const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); + const [schemes, setSchemes] = useState<SchemeRecord[]>([]); // Using controlled or uncontrolled state const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; @@ -183,7 +185,7 @@ const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({ </TabPanel> <TabPanel value={currentTab} index={1}> - <SchemeQuery /> + <SchemeQuery schemes={schemes} onSchemesChange={setSchemes} /> </TabPanel> </Box> </Drawer> diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index b37a35a..fc56bf8 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -238,17 +238,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ }); } - setSchemes( - filteredResults.map((item: SchemaItem) => ({ - id: item.scheme_id, - schemeName: item.scheme_name, - type: item.scheme_type, - user: item.username, - create_time: item.create_time, - startTime: item.scheme_start_time, - schemeDetail: item.scheme_detail, - })), - ); + const nextSchemes = filteredResults.map((item: SchemaItem) => ({ + id: item.scheme_id, + schemeName: item.scheme_name, + type: item.scheme_type, + user: item.username, + create_time: item.create_time, + startTime: item.scheme_start_time, + schemeDetail: item.scheme_detail, + })); + setSchemes(nextSchemes); if (filteredResults.length === 0) { open?.({ diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index be17175..da9899c 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -163,17 +163,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ }); } - setSchemes( - filteredResults.map((item: SchemaItem) => ({ - id: item.id, - schemeName: item.scheme_name, - sensorNumber: item.sensor_number, - minDiameter: item.min_diameter, - user: item.username, - create_time: item.create_time, - sensorLocation: item.sensor_location, - })), - ); + const nextSchemes = filteredResults.map((item: SchemaItem) => ({ + id: item.id, + schemeName: item.scheme_name, + sensorNumber: item.sensor_number, + minDiameter: item.min_diameter, + user: item.username, + create_time: item.create_time, + sensorLocation: item.sensor_location, + })); + setSchemes(nextSchemes); if (filteredResults.length === 0) { open?.({ -- 2.54.0 From 427cbe70b35d7886b183b5f719d5609be723c36c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 11:25:08 +0800 Subject: [PATCH 081/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=20URL=20=E4=B8=BA=E6=AD=A3=E5=BC=8F=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index 1a8f4e1..926953d 100644 --- a/.env +++ b/.env @@ -7,7 +7,7 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/" # 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn" NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn" -NEXT_PUBLIC_AUDIO_SERVICE_URL="http://127.0.0.1:18083" +NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn" NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" NEXT_PUBLIC_MAP_WORKSPACE="tjwater" NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" -- 2.54.0 From 8b6dda08e66fbd6cbd20158fc12e3d5d9d3161fd Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 11:43:37 +0800 Subject: [PATCH 082/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20gitea=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <copilot@github.com> --- .gitea/workflows/package.yml | 62 +++++++++++++++++++++++++++++++++++ .github/workflows/package.yml | 42 ------------------------ 2 files changed, 62 insertions(+), 42 deletions(-) create mode 100644 .gitea/workflows/package.yml delete mode 100644 .github/workflows/package.yml diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml new file mode 100644 index 0000000..5383db3 --- /dev/null +++ b/.gitea/workflows/package.yml @@ -0,0 +1,62 @@ +name: Build Push and Deploy + +on: + push: + tags: + - "v*" + +jobs: + docker-image: + runs-on: ubuntu + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_HOST }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and Push Image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ secrets.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} + ${{ secrets.REGISTRY_HOST }}/${{ github.repository }}:latest + build-args: | + NEXT_PUBLIC_BACKEND_URL=${{ secrets.NEXT_PUBLIC_BACKEND_URL }} + NEXT_PUBLIC_MAP_URL=${{ secrets.NEXT_PUBLIC_MAP_URL }} + NEXT_PUBLIC_MAP_WORKSPACE=${{ secrets.NEXT_PUBLIC_MAP_WORKSPACE }} + NEXT_PUBLIC_MAP_EXTENT=${{ secrets.NEXT_PUBLIC_MAP_EXTENT }} + NEXT_PUBLIC_NETWORK_NAME=${{ secrets.NEXT_PUBLIC_NETWORK_NAME }} + NEXT_PUBLIC_MAPBOX_TOKEN=${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }} + NEXT_PUBLIC_TIANDITU_TOKEN=${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }} + + - name: Notify Deploy Server + if: success() + env: + IMAGE: ${{ secrets.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} + run: | + curl -fsSL -X POST "${{ secrets.DEPLOY_WEBHOOK_URL }}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ + -d "{\"image\":\"${IMAGE}\",\"tag\":\"${{ github.ref_name }}\",\"repo\":\"${{ github.repository }}\"}" + + deploy-fallback-log: + runs-on: ubuntu + needs: docker-image + if: failure() + steps: + - name: Deployment not triggered + run: echo "Image build/push failed, deployment webhook was not called." diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml deleted file mode 100644 index e110f47..0000000 --- a/.github/workflows/package.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build and Lint - -on: - push: - tags: - - "v*" - -jobs: - build: - runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - strategy: - matrix: - node-version: [24.x] - - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Package Source Code - run: | - tar --warning=no-file-changed -czf source-code.tar.gz \ - --exclude='node_modules' \ - --exclude='.next' \ - --exclude='dist' \ - --exclude='source-code.tar.gz' \ - . - - - name: Upload Source Artifact - uses: actions/upload-artifact@v4 - with: - name: source-code - path: source-code.tar.gz -- 2.54.0 From 5aa28c8409c8cf9d2c1166968aa5c4b280aac472 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 11:52:49 +0800 Subject: [PATCH 083/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20API=20URL=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=9B=B4=E6=96=B0=20Dockerfile=20?= =?UTF-8?q?=E5=92=8C=20docker-compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <copilot@github.com> --- .env | 2 +- .gitea/workflows/package.yml | 3 +++ Dockerfile | 3 +++ docker-compose.yml | 34 ++++++++++++++++++++++++++++++++++ src/config/config.ts | 14 +++++++++----- 5 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 docker-compose.yml diff --git a/.env b/.env index 926953d..7a87b18 100644 --- a/.env +++ b/.env @@ -11,7 +11,7 @@ NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn" NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" NEXT_PUBLIC_MAP_WORKSPACE="tjwater" NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" -# NEXT_PUBLIC_MAP_AVAILABLE_LAYERS="junctions, pipes, reservoirs, scada" NEXT_PUBLIC_NETWORK_NAME="tjwater" NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg" NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec" +NEXT_PUBLIC_API_URL="https://server.waternetwork.cn" diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 5383db3..6a397c8 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -36,12 +36,15 @@ jobs: ${{ secrets.REGISTRY_HOST }}/${{ github.repository }}:latest build-args: | NEXT_PUBLIC_BACKEND_URL=${{ secrets.NEXT_PUBLIC_BACKEND_URL }} + NEXT_PUBLIC_COPILOT_URL=${{ secrets.NEXT_PUBLIC_COPILOT_URL }} + NEXT_PUBLIC_AUDIO_SERVICE_URL=${{ secrets.NEXT_PUBLIC_AUDIO_SERVICE_URL }} NEXT_PUBLIC_MAP_URL=${{ secrets.NEXT_PUBLIC_MAP_URL }} NEXT_PUBLIC_MAP_WORKSPACE=${{ secrets.NEXT_PUBLIC_MAP_WORKSPACE }} NEXT_PUBLIC_MAP_EXTENT=${{ secrets.NEXT_PUBLIC_MAP_EXTENT }} NEXT_PUBLIC_NETWORK_NAME=${{ secrets.NEXT_PUBLIC_NETWORK_NAME }} NEXT_PUBLIC_MAPBOX_TOKEN=${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }} NEXT_PUBLIC_TIANDITU_TOKEN=${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }} + NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} - name: Notify Deploy Server if: success() diff --git a/Dockerfile b/Dockerfile index d299ac4..b6a85e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,12 +18,15 @@ FROM base AS builder # 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数 # Next.js 在 build 时会自动读取同名的 ARG 作为环境变量 ARG NEXT_PUBLIC_BACKEND_URL +ARG NEXT_PUBLIC_COPILOT_URL +ARG NEXT_PUBLIC_AUDIO_SERVICE_URL ARG NEXT_PUBLIC_MAP_URL ARG NEXT_PUBLIC_MAP_WORKSPACE ARG NEXT_PUBLIC_MAP_EXTENT ARG NEXT_PUBLIC_NETWORK_NAME ARG NEXT_PUBLIC_MAPBOX_TOKEN ARG NEXT_PUBLIC_TIANDITU_TOKEN +ARG NEXT_PUBLIC_API_URL COPY --from=deps /app/refine/node_modules ./node_modules diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e304802 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.9" + +services: + frontend: + image: ${IMAGE_NAME:-refinedev/tjwater-frontend:latest} + build: + context: . + dockerfile: Dockerfile + args: + NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL} + NEXT_PUBLIC_COPILOT_URL: ${NEXT_PUBLIC_COPILOT_URL} + NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL} + NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL} + NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE} + NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT} + NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME} + NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN} + NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} + env_file: + - .env + environment: + KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} + KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} + KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXTAUTH_URL: ${NEXTAUTH_URL} + NODE_ENV: production + HOSTNAME: 0.0.0.0 + PORT: 3000 + ports: + - "3000:3000" + restart: unless-stopped + pull_policy: always diff --git a/src/config/config.ts b/src/config/config.ts index 9a9a256..a5bf841 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -23,11 +23,15 @@ export const config = { 8, // 在缩放级别 24 时,圆形半径为 8px ], }, - MAP_AVAILABLE_LAYERS: process.env.NEXT_PUBLIC_MAP_AVAILABLE_LAYERS - ? process.env.NEXT_PUBLIC_MAP_AVAILABLE_LAYERS.split(",").map((item) => - item.trim().toLowerCase(), - ) - : ["junctions", "pipes", "valves", "reservoirs", "pumps", "tanks", "scada"], + MAP_AVAILABLE_LAYERS: [ + "junctions", + "pipes", + "valves", + "reservoirs", + "pumps", + "tanks", + "scada", + ], }; export let NETWORK_NAME = process.env.NEXT_PUBLIC_NETWORK_NAME || "tjwater"; -- 2.54.0 From efd04fd651d8be7a8d5b02e55b6b93185aea0287 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 11:57:57 +0800 Subject: [PATCH 084/281] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20API=20URL=20?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <copilot@github.com> --- .env | 1 - .gitea/workflows/package.yml | 1 - Dockerfile | 1 - docker-compose.yml | 1 - src/lib/api.ts | 2 +- 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.env b/.env index 7a87b18..04d8be8 100644 --- a/.env +++ b/.env @@ -14,4 +14,3 @@ NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" NEXT_PUBLIC_NETWORK_NAME="tjwater" NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg" NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec" -NEXT_PUBLIC_API_URL="https://server.waternetwork.cn" diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 6a397c8..a8c4828 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -44,7 +44,6 @@ jobs: NEXT_PUBLIC_NETWORK_NAME=${{ secrets.NEXT_PUBLIC_NETWORK_NAME }} NEXT_PUBLIC_MAPBOX_TOKEN=${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }} NEXT_PUBLIC_TIANDITU_TOKEN=${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }} - NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} - name: Notify Deploy Server if: success() diff --git a/Dockerfile b/Dockerfile index b6a85e8..282bfa8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,6 @@ ARG NEXT_PUBLIC_MAP_EXTENT ARG NEXT_PUBLIC_NETWORK_NAME ARG NEXT_PUBLIC_MAPBOX_TOKEN ARG NEXT_PUBLIC_TIANDITU_TOKEN -ARG NEXT_PUBLIC_API_URL COPY --from=deps /app/refine/node_modules ./node_modules diff --git a/docker-compose.yml b/docker-compose.yml index e304802..52a00e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,6 @@ services: NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME} NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN} NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN} - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} env_file: - .env environment: diff --git a/src/lib/api.ts b/src/lib/api.ts index 88134f9..8e66f76 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,7 +7,7 @@ import { type AuthContextHeaderOptions, } from "@/lib/requestHeaders"; -export const API_URL = process.env.NEXT_PUBLIC_API_URL || config.BACKEND_URL; +export const API_URL = config.BACKEND_URL; export const api = axios.create({ baseURL: API_URL, -- 2.54.0 From 74b4a4157c06433687f756808a931c1e390990c1 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 15:18:50 +0800 Subject: [PATCH 085/281] =?UTF-8?q?=E5=8C=BA=E5=88=86=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <copilot@github.com> --- .gitea/workflows/package.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index a8c4828..12b12f8 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -21,7 +21,7 @@ jobs: - name: Login to Gitea Container Registry uses: docker/login-action@v3 with: - registry: ${{ secrets.REGISTRY_HOST }} + registry: ${{ vars.REGISTRY_HOST }} username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} @@ -32,25 +32,25 @@ jobs: file: ./Dockerfile push: true tags: | - ${{ secrets.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} - ${{ secrets.REGISTRY_HOST }}/${{ github.repository }}:latest + ${{ vars.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} + ${{ vars.REGISTRY_HOST }}/${{ github.repository }}:latest build-args: | - NEXT_PUBLIC_BACKEND_URL=${{ secrets.NEXT_PUBLIC_BACKEND_URL }} - NEXT_PUBLIC_COPILOT_URL=${{ secrets.NEXT_PUBLIC_COPILOT_URL }} - NEXT_PUBLIC_AUDIO_SERVICE_URL=${{ secrets.NEXT_PUBLIC_AUDIO_SERVICE_URL }} - NEXT_PUBLIC_MAP_URL=${{ secrets.NEXT_PUBLIC_MAP_URL }} - NEXT_PUBLIC_MAP_WORKSPACE=${{ secrets.NEXT_PUBLIC_MAP_WORKSPACE }} - NEXT_PUBLIC_MAP_EXTENT=${{ secrets.NEXT_PUBLIC_MAP_EXTENT }} - NEXT_PUBLIC_NETWORK_NAME=${{ secrets.NEXT_PUBLIC_NETWORK_NAME }} + NEXT_PUBLIC_BACKEND_URL=${{ vars.NEXT_PUBLIC_BACKEND_URL }} + NEXT_PUBLIC_COPILOT_URL=${{ vars.NEXT_PUBLIC_COPILOT_URL }} + NEXT_PUBLIC_AUDIO_SERVICE_URL=${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }} + NEXT_PUBLIC_MAP_URL=${{ vars.NEXT_PUBLIC_MAP_URL }} + NEXT_PUBLIC_MAP_WORKSPACE=${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }} + NEXT_PUBLIC_MAP_EXTENT=${{ vars.NEXT_PUBLIC_MAP_EXTENT }} + NEXT_PUBLIC_NETWORK_NAME=${{ vars.NEXT_PUBLIC_NETWORK_NAME }} NEXT_PUBLIC_MAPBOX_TOKEN=${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }} NEXT_PUBLIC_TIANDITU_TOKEN=${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }} - name: Notify Deploy Server if: success() env: - IMAGE: ${{ secrets.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} + IMAGE: ${{ vars.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} run: | - curl -fsSL -X POST "${{ secrets.DEPLOY_WEBHOOK_URL }}" \ + curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ -d "{\"image\":\"${IMAGE}\",\"tag\":\"${{ github.ref_name }}\",\"repo\":\"${{ github.repository }}\"}" -- 2.54.0 From 1debaed7eae1a75576c0c42a62b2e44a5841998a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 17:40:44 +0800 Subject: [PATCH 086/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20Checkout=20?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=EF=BC=8C=E6=B7=BB=E5=8A=A0=20GitHub=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=20URL=20=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20Gitea=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <copilot@github.com> --- .gitea/workflows/package.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 12b12f8..1999770 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -14,6 +14,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + github-server-url: ${{ github.server_url }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 -- 2.54.0 From a23626614f7a497858df97d8d36fea65fe14d8d0 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 17:48:52 +0800 Subject: [PATCH 087/281] ci: run gitea job in node container --- .gitea/workflows/package.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 1999770..5058a8d 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -4,6 +4,8 @@ on: push: tags: - "v*" + container: + image: node:20-bookworm jobs: docker-image: @@ -11,6 +13,11 @@ jobs: permissions: contents: read + - name: Install Docker CLI + run: | + apt-get update + apt-get install -y docker.io curl + steps: - name: Checkout code uses: actions/checkout@v4 -- 2.54.0 From 0f110ce0c6f73454c964bbb803cbbe3329ec2704 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 17:50:09 +0800 Subject: [PATCH 088/281] ci: run gitea workflow on node runner --- .gitea/workflows/package.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 5058a8d..cf62854 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -4,20 +4,13 @@ on: push: tags: - "v*" - container: - image: node:20-bookworm jobs: docker-image: - runs-on: ubuntu + runs-on: node permissions: contents: read - - name: Install Docker CLI - run: | - apt-get update - apt-get install -y docker.io curl - steps: - name: Checkout code uses: actions/checkout@v4 @@ -67,7 +60,7 @@ jobs: -d "{\"image\":\"${IMAGE}\",\"tag\":\"${{ github.ref_name }}\",\"repo\":\"${{ github.repository }}\"}" deploy-fallback-log: - runs-on: ubuntu + runs-on: node needs: docker-image if: failure() steps: -- 2.54.0 From 4f195b0e06fbae7615f6be223d21dfa87cdb36c4 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 23 Apr 2026 17:53:17 +0800 Subject: [PATCH 089/281] ci: pin action versions for gitea runner --- .gitea/workflows/package.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index cf62854..60d248d 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -13,24 +13,24 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v4.3.1 with: repository: ${{ github.repository }} ref: ${{ github.ref }} github-server-url: ${{ github.server_url }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v3.9.0 - name: Login to Gitea Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v3.7.0 with: registry: ${{ vars.REGISTRY_HOST }} username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: Build and Push Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v6.9.0 with: context: . file: ./Dockerfile -- 2.54.0 From f207e2b192d0a172c6fe3cbeb000081b21edb231 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 09:19:42 +0800 Subject: [PATCH 090/281] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=96=B9=E6=A1=88?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=91=BD=E5=90=8D=E4=B8=BA=E5=B0=8F=E5=86=99?= =?UTF-8?q?=E5=BD=A2=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/core/Controls/HistoryDataPanel.tsx | 2 +- src/components/olmap/core/Controls/Timeline.tsx | 2 +- src/components/olmap/core/Controls/Toolbar.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 39b38b3..ae21ab5 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -396,7 +396,7 @@ const emptyStateMessages: Record< const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ featureInfos, type = "none", - scheme_type = "burst_Analysis", + scheme_type = "burst_analysis", scheme_name, defaultTab = "chart", fractionDigits = 2, diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index f883634..5497b9a 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -47,7 +47,7 @@ const Timeline: React.FC<TimelineProps> = ({ timeRange, disableDateSelection = false, schemeName = "", - schemeType = "burst_Analysis", + schemeType = "burst_analysis", }) => { const data = useData(); const fallbackSelectedDateRef = useRef(new Date()); diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 29946b5..1e7d2f8 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -921,7 +921,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ }) .filter(Boolean) as [string, string][]; })()} - scheme_type="burst_Analysis" + scheme_type="burst_analysis" scheme_name={schemeName} type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} start_time={chatPanelTimeRange?.startTime} @@ -965,7 +965,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ }) .filter(Boolean) as [string, string][]; })()} - scheme_type="burst_Analysis" + scheme_type="burst_analysis" scheme_name={schemeName} type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} start_time={chatPanelTimeRange?.startTime} -- 2.54.0 From 333d0d3353e743f82a4a1b21399c835870d32ddd Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 14:23:48 +0800 Subject: [PATCH 091/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E7=89=88=E6=9C=AC=EF=BC=8C=E7=AE=80=E5=8C=96=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/package.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 60d248d..8c84269 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -13,24 +13,20 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4.3.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - github-server-url: ${{ github.server_url }} + uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.9.0 + uses: docker/setup-buildx-action@v3 - name: Login to Gitea Container Registry - uses: docker/login-action@v3.7.0 + uses: docker/login-action@v3 with: registry: ${{ vars.REGISTRY_HOST }} username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: Build and Push Image - uses: docker/build-push-action@v6.9.0 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile -- 2.54.0 From bfd41b58e38e5394cfde82683b5c71fbd9bf22bc Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 14:25:10 +0800 Subject: [PATCH 092/281] ci: fix checkout server url for gitea --- .gitea/workflows/package.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 8c84269..2a098f0 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + github-server-url: ${{ github.server_url }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 -- 2.54.0 From b963562a5ffa832a18bab3a56660a39c1d243174 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 14:34:42 +0800 Subject: [PATCH 093/281] ci: add git bootstrap for runner --- .gitea/workflows/package.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 2a098f0..f5d8131 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -12,6 +12,28 @@ jobs: contents: read steps: + - name: Ensure Git is available + run: | + if command -v git >/dev/null 2>&1; then + git --version + exit 0 + fi + + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y git + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache git + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git + elif command -v yum >/dev/null 2>&1; then + yum install -y git + else + echo "No supported package manager found to install git" + exit 1 + fi + + git --version + - name: Checkout code uses: actions/checkout@v4 with: -- 2.54.0 From e81305d0465c2fa640d19f80c71268068dfacb71 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 14:36:38 +0800 Subject: [PATCH 094/281] ci: use sh shell for gitea runner compatibility --- .gitea/workflows/package.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index f5d8131..d5095f2 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -10,6 +10,9 @@ jobs: runs-on: node permissions: contents: read + defaults: + run: + shell: sh steps: - name: Ensure Git is available -- 2.54.0 From 05868c6af64b1c1f51d3df1877c633fb9286c27a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 14:39:29 +0800 Subject: [PATCH 095/281] ci: bootstrap docker cli in runner --- .gitea/workflows/package.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index d5095f2..134a072 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -42,6 +42,28 @@ jobs: with: github-server-url: ${{ github.server_url }} + - name: Ensure Docker CLI is available + run: | + if command -v docker >/dev/null 2>&1; then + docker --version + exit 0 + fi + + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y docker.io + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache docker-cli + elif command -v dnf >/dev/null 2>&1; then + dnf install -y docker + elif command -v yum >/dev/null 2>&1; then + yum install -y docker + else + echo "No supported package manager found to install docker" + exit 1 + fi + + docker --version + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 -- 2.54.0 From baa5d41bec126f071f070dfb9e770b004f002c01 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:06:55 +0800 Subject: [PATCH 096/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E7=8E=AF=E5=A2=83=EF=BC=8C=E7=A7=BB=E9=99=A4=20Git=20?= =?UTF-8?q?=E5=92=8C=20Docker=20=E5=AE=89=E8=A3=85=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <copilot@github.com> --- .gitea/workflows/package.yml | 48 ++---------------------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 134a072..f0ca174 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -7,7 +7,7 @@ on: jobs: docker-image: - runs-on: node + runs-on: ubuntu permissions: contents: read defaults: @@ -15,55 +15,11 @@ jobs: shell: sh steps: - - name: Ensure Git is available - run: | - if command -v git >/dev/null 2>&1; then - git --version - exit 0 - fi - - if command -v apt-get >/dev/null 2>&1; then - apt-get update && apt-get install -y git - elif command -v apk >/dev/null 2>&1; then - apk add --no-cache git - elif command -v dnf >/dev/null 2>&1; then - dnf install -y git - elif command -v yum >/dev/null 2>&1; then - yum install -y git - else - echo "No supported package manager found to install git" - exit 1 - fi - - git --version - - name: Checkout code uses: actions/checkout@v4 with: github-server-url: ${{ github.server_url }} - - name: Ensure Docker CLI is available - run: | - if command -v docker >/dev/null 2>&1; then - docker --version - exit 0 - fi - - if command -v apt-get >/dev/null 2>&1; then - apt-get update && apt-get install -y docker.io - elif command -v apk >/dev/null 2>&1; then - apk add --no-cache docker-cli - elif command -v dnf >/dev/null 2>&1; then - dnf install -y docker - elif command -v yum >/dev/null 2>&1; then - yum install -y docker - else - echo "No supported package manager found to install docker" - exit 1 - fi - - docker --version - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -105,7 +61,7 @@ jobs: -d "{\"image\":\"${IMAGE}\",\"tag\":\"${{ github.ref_name }}\",\"repo\":\"${{ github.repository }}\"}" deploy-fallback-log: - runs-on: node + runs-on: ubuntu needs: docker-image if: failure() steps: -- 2.54.0 From 1ed09c9594db0c1c72aa7fef1f84db90d5b9823a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:10:18 +0800 Subject: [PATCH 097/281] chore(workflow): use host docker instead of buildx --- .gitea/workflows/package.yml | 41 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index f0ca174..931111d 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -20,9 +20,6 @@ jobs: with: github-server-url: ${{ github.server_url }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Gitea Container Registry uses: docker/login-action@v3 with: @@ -31,24 +28,26 @@ jobs: password: ${{ secrets.REGISTRY_PASSWORD }} - name: Build and Push Image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - push: true - tags: | - ${{ vars.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} - ${{ vars.REGISTRY_HOST }}/${{ github.repository }}:latest - build-args: | - NEXT_PUBLIC_BACKEND_URL=${{ vars.NEXT_PUBLIC_BACKEND_URL }} - NEXT_PUBLIC_COPILOT_URL=${{ vars.NEXT_PUBLIC_COPILOT_URL }} - NEXT_PUBLIC_AUDIO_SERVICE_URL=${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }} - NEXT_PUBLIC_MAP_URL=${{ vars.NEXT_PUBLIC_MAP_URL }} - NEXT_PUBLIC_MAP_WORKSPACE=${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }} - NEXT_PUBLIC_MAP_EXTENT=${{ vars.NEXT_PUBLIC_MAP_EXTENT }} - NEXT_PUBLIC_NETWORK_NAME=${{ vars.NEXT_PUBLIC_NETWORK_NAME }} - NEXT_PUBLIC_MAPBOX_TOKEN=${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }} - NEXT_PUBLIC_TIANDITU_TOKEN=${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }} + env: + IMAGE_NAME: ${{ vars.REGISTRY_HOST }}/${{ github.repository }} + IMAGE_TAG: ${{ github.ref_name }} + run: | + docker build \ + -f ./Dockerfile \ + -t "${IMAGE_NAME}:${IMAGE_TAG}" \ + -t "${IMAGE_NAME}:latest" \ + --build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \ + --build-arg NEXT_PUBLIC_COPILOT_URL="${{ vars.NEXT_PUBLIC_COPILOT_URL }}" \ + --build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \ + --build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \ + --build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \ + --build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \ + --build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \ + --build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \ + --build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \ + . + docker push "${IMAGE_NAME}:${IMAGE_TAG}" + docker push "${IMAGE_NAME}:latest" - name: Notify Deploy Server if: success() -- 2.54.0 From c2785f074607839e78360494c1d7b0d339a93e29 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:15:03 +0800 Subject: [PATCH 098/281] chore: normalize registry host for docker image refs --- .gitea/workflows/package.yml | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 931111d..4b0af5a 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -20,17 +20,32 @@ jobs: with: github-server-url: ${{ github.server_url }} + - name: Normalize image metadata + env: + RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }} + RAW_REPOSITORY: ${{ github.repository }} + IMAGE_TAG: ${{ github.ref_name }} + run: | + REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}" + REGISTRY_HOST="${REGISTRY_HOST#https://}" + REGISTRY_HOST="${REGISTRY_HOST%/}" + REPOSITORY_PATH="${RAW_REPOSITORY#/}" + IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}" + { + echo "REGISTRY_HOST=${REGISTRY_HOST}" + echo "REPOSITORY_PATH=${REPOSITORY_PATH}" + echo "IMAGE_NAME=${IMAGE_NAME}" + echo "IMAGE_TAG=${IMAGE_TAG}" + echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}" + } >> "$GITHUB_ENV" + - name: Login to Gitea Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ vars.REGISTRY_HOST }} - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \ + --username "${{ secrets.REGISTRY_USERNAME }}" \ + --password-stdin - name: Build and Push Image - env: - IMAGE_NAME: ${{ vars.REGISTRY_HOST }}/${{ github.repository }} - IMAGE_TAG: ${{ github.ref_name }} run: | docker build \ -f ./Dockerfile \ @@ -51,13 +66,11 @@ jobs: - name: Notify Deploy Server if: success() - env: - IMAGE: ${{ vars.REGISTRY_HOST }}/${{ github.repository }}:${{ github.ref_name }} run: | curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ - -d "{\"image\":\"${IMAGE}\",\"tag\":\"${{ github.ref_name }}\",\"repo\":\"${{ github.repository }}\"}" + -d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" deploy-fallback-log: runs-on: ubuntu -- 2.54.0 From b99fe667046ccd7564d1361abe6e3ae4a5eef60a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:19:35 +0800 Subject: [PATCH 099/281] refactor: checkout no longer depends on node actions --- .gitea/workflows/package.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 4b0af5a..46347ee 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -16,9 +16,29 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 - with: - github-server-url: ${{ github.server_url }} + env: + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + GIT_USERNAME: ${{ github.actor }} + GIT_TOKEN: ${{ github.token }} + run: | + case "$SERVER_URL" in + http://*) + AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}" + ;; + https://*) + AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}" + ;; + *) + AUTH_SERVER_URL="$SERVER_URL" + ;; + esac + + git init . + git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + git fetch --depth=1 origin "$COMMIT_SHA" + git checkout --detach FETCH_HEAD - name: Normalize image metadata env: -- 2.54.0 From 3afe885cc0fd9c8ae626b7b3109bcc7406243760 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:31:01 +0800 Subject: [PATCH 100/281] ci: harden gitea package workflow Make checkout idempotent for reused runner workspaces and add a safe test-tag path that validates builds without pushing images or calling the deploy webhook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 34 +++++++++++++++++++++++++++++++--- memery.md | 10 ++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 memery.md diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 46347ee..b86c643 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -35,10 +35,19 @@ jobs: ;; esac - git init . - git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + if [ ! -d .git ]; then + git init . + fi + + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + else + git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + fi + git fetch --depth=1 origin "$COMMIT_SHA" - git checkout --detach FETCH_HEAD + git checkout --force --detach FETCH_HEAD + git clean -ffdx - name: Normalize image metadata env: @@ -51,16 +60,26 @@ jobs: REGISTRY_HOST="${REGISTRY_HOST%/}" REPOSITORY_PATH="${RAW_REPOSITORY#/}" IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}" + case "$IMAGE_TAG" in + *-test) IS_TEST_TAG=true ;; + *) IS_TEST_TAG=false ;; + esac { echo "REGISTRY_HOST=${REGISTRY_HOST}" echo "REPOSITORY_PATH=${REPOSITORY_PATH}" echo "IMAGE_NAME=${IMAGE_NAME}" echo "IMAGE_TAG=${IMAGE_TAG}" echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}" + echo "IS_TEST_TAG=${IS_TEST_TAG}" } >> "$GITHUB_ENV" - name: Login to Gitea Container Registry run: | + if [ "$IS_TEST_TAG" = "true" ]; then + echo "Test tag detected; skipping registry login." + exit 0 + fi + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \ --username "${{ secrets.REGISTRY_USERNAME }}" \ --password-stdin @@ -81,12 +100,21 @@ jobs: --build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \ --build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \ . + if [ "$IS_TEST_TAG" = "true" ]; then + echo "Test tag detected; build completed without pushing images." + exit 0 + fi docker push "${IMAGE_NAME}:${IMAGE_TAG}" docker push "${IMAGE_NAME}:latest" - name: Notify Deploy Server if: success() run: | + if [ "$IS_TEST_TAG" = "true" ]; then + echo "Test tag detected; skipping deploy webhook." + exit 0 + fi + curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ diff --git a/memery.md b/memery.md new file mode 100644 index 0000000..310297d --- /dev/null +++ b/memery.md @@ -0,0 +1,10 @@ +# CI build notes + +## 2026-04-24 + +- **Observed failure while reproducing workflow checkout locally:** the `Checkout code` step ran `git remote add origin ...` unconditionally. In a workspace that already had an `origin` remote, the job failed with `error: remote origin already exists.` and exited before `docker build`. +- **Why this matters for act_runner:** self-hosted Gitea runners can reuse working directories or start from repositories that already contain Git metadata, so checkout logic must be idempotent. +- **Applied fix:** changed `.gitea/workflows/package.yml` to initialize Git only when needed, use `git remote set-url origin ...` when `origin` already exists, and force-clean the workspace after checking out `FETCH_HEAD`. +- **Safety improvement for remote validation:** tags ending with `-test` now run the build verification path only. They skip registry login, image push, `latest` updates, and the deploy webhook so act_runner can be tested without deployment side effects. +- **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. +- **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From c4269f40e3209f821baa158b0459cc0d9187403b Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:43:36 +0800 Subject: [PATCH 101/281] ci: pin gitea runner image Use the full ubuntu runner label so Gitea Actions resolves gitea/runner-images:ubuntu-22.04 instead of falling back to ubuntu:latest during test runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 4 ++-- memery.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index b86c643..472f8b2 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -7,7 +7,7 @@ on: jobs: docker-image: - runs-on: ubuntu + runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04" permissions: contents: read defaults: @@ -121,7 +121,7 @@ jobs: -d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" deploy-fallback-log: - runs-on: ubuntu + runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04" needs: docker-image if: failure() steps: diff --git a/memery.md b/memery.md index 310297d..1698838 100644 --- a/memery.md +++ b/memery.md @@ -6,5 +6,7 @@ - **Why this matters for act_runner:** self-hosted Gitea runners can reuse working directories or start from repositories that already contain Git metadata, so checkout logic must be idempotent. - **Applied fix:** changed `.gitea/workflows/package.yml` to initialize Git only when needed, use `git remote set-url origin ...` when `origin` already exists, and force-clean the workspace after checking out `FETCH_HEAD`. - **Safety improvement for remote validation:** tags ending with `-test` now run the build verification path only. They skip registry login, image push, `latest` updates, and the deploy webhook so act_runner can be tested without deployment side effects. +- **Root cause found on the real act_runner:** although the runner was registered with `ubuntu:docker://gitea/runner-images:ubuntu-22.04`, the workflow used `runs-on: ubuntu`, and the job log showed `Start image=ubuntu:latest`. That default image does not include the expected toolset, which explains the remote `git: not found` failure. +- **Applied fix for label selection:** changed both jobs to `runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04"` so Gitea resolves the exact runner image instead of falling back to `ubuntu:latest`. - **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. - **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From 23bd2f47c334be264094b61c49336044a507ec9d Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:50:39 +0800 Subject: [PATCH 102/281] ci: use ubuntu-22.04 runner label Switch runs-on to the short ubuntu-22.04 label so Gitea matches the online runner mapping to gitea/runner-images:ubuntu-22.04. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 4 ++-- memery.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 472f8b2..b44671b 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -7,7 +7,7 @@ on: jobs: docker-image: - runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04" + runs-on: ubuntu-22.04 permissions: contents: read defaults: @@ -121,7 +121,7 @@ jobs: -d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" deploy-fallback-log: - runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04" + runs-on: ubuntu-22.04 needs: docker-image if: failure() steps: diff --git a/memery.md b/memery.md index 1698838..fb9ecac 100644 --- a/memery.md +++ b/memery.md @@ -8,5 +8,6 @@ - **Safety improvement for remote validation:** tags ending with `-test` now run the build verification path only. They skip registry login, image push, `latest` updates, and the deploy webhook so act_runner can be tested without deployment side effects. - **Root cause found on the real act_runner:** although the runner was registered with `ubuntu:docker://gitea/runner-images:ubuntu-22.04`, the workflow used `runs-on: ubuntu`, and the job log showed `Start image=ubuntu:latest`. That default image does not include the expected toolset, which explains the remote `git: not found` failure. - **Applied fix for label selection:** changed both jobs to `runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04"` so Gitea resolves the exact runner image instead of falling back to `ubuntu:latest`. +- **Follow-up from server validation:** Gitea then reported `No matching online runner with label: ubuntu:docker://gitea/runner-images:ubuntu-22.04`. The runner advertises the short label `ubuntu-22.04`, so the workflow was updated again to use `runs-on: ubuntu-22.04`, which should map to `docker://gitea/runner-images:ubuntu-22.04` on the runner side. - **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. - **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From 9206c480b2894a5d948919948ac3f240ac8119ea Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:54:03 +0800 Subject: [PATCH 103/281] ci: lowercase image repository Normalize github.repository to lowercase before composing Docker image tags so Gitea registry references stay valid on the runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 1 + memery.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index b44671b..790f6ec 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -59,6 +59,7 @@ jobs: REGISTRY_HOST="${REGISTRY_HOST#https://}" REGISTRY_HOST="${REGISTRY_HOST%/}" REPOSITORY_PATH="${RAW_REPOSITORY#/}" + REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}" case "$IMAGE_TAG" in *-test) IS_TEST_TAG=true ;; diff --git a/memery.md b/memery.md index fb9ecac..edf3e3c 100644 --- a/memery.md +++ b/memery.md @@ -9,5 +9,7 @@ - **Root cause found on the real act_runner:** although the runner was registered with `ubuntu:docker://gitea/runner-images:ubuntu-22.04`, the workflow used `runs-on: ubuntu`, and the job log showed `Start image=ubuntu:latest`. That default image does not include the expected toolset, which explains the remote `git: not found` failure. - **Applied fix for label selection:** changed both jobs to `runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04"` so Gitea resolves the exact runner image instead of falling back to `ubuntu:latest`. - **Follow-up from server validation:** Gitea then reported `No matching online runner with label: ubuntu:docker://gitea/runner-images:ubuntu-22.04`. The runner advertises the short label `ubuntu-22.04`, so the workflow was updated again to use `runs-on: ubuntu-22.04`, which should map to `docker://gitea/runner-images:ubuntu-22.04` on the runner side. +- **Next remote failure on act_runner:** Docker rejected the tag `gitea.waternetwork.cn/OrgTJWater/TJWaterFrontend_Refine:v2026.04.24-test3` with `repository name must be lowercase`. The workflow had normalized the registry host but not the repository path from `github.repository`. +- **Applied fix for image naming:** lowercased `REPOSITORY_PATH` during image metadata normalization so image tags remain valid even when the Gitea owner or repository name contains uppercase letters. - **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. - **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From 5ca9a55a7b4aafc62a54669178b695e2b78419e5 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:57:38 +0800 Subject: [PATCH 104/281] ci: skip deploy steps for test tags Use workflow-level conditions for registry login and deploy webhook steps so *-test tags only validate the build path on Gitea Actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 15 +++------------ memery.md | 2 ++ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 790f6ec..eef9682 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -75,12 +75,8 @@ jobs: } >> "$GITHUB_ENV" - name: Login to Gitea Container Registry + if: ${{ !endsWith(github.ref_name, '-test') }} run: | - if [ "$IS_TEST_TAG" = "true" ]; then - echo "Test tag detected; skipping registry login." - exit 0 - fi - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \ --username "${{ secrets.REGISTRY_USERNAME }}" \ --password-stdin @@ -101,7 +97,7 @@ jobs: --build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \ --build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \ . - if [ "$IS_TEST_TAG" = "true" ]; then + if [ "${{ endsWith(github.ref_name, '-test') }}" = "true" ]; then echo "Test tag detected; build completed without pushing images." exit 0 fi @@ -109,13 +105,8 @@ jobs: docker push "${IMAGE_NAME}:latest" - name: Notify Deploy Server - if: success() + if: ${{ success() && !endsWith(github.ref_name, '-test') }} run: | - if [ "$IS_TEST_TAG" = "true" ]; then - echo "Test tag detected; skipping deploy webhook." - exit 0 - fi - curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ diff --git a/memery.md b/memery.md index edf3e3c..e93b1b8 100644 --- a/memery.md +++ b/memery.md @@ -11,5 +11,7 @@ - **Follow-up from server validation:** Gitea then reported `No matching online runner with label: ubuntu:docker://gitea/runner-images:ubuntu-22.04`. The runner advertises the short label `ubuntu-22.04`, so the workflow was updated again to use `runs-on: ubuntu-22.04`, which should map to `docker://gitea/runner-images:ubuntu-22.04` on the runner side. - **Next remote failure on act_runner:** Docker rejected the tag `gitea.waternetwork.cn/OrgTJWater/TJWaterFrontend_Refine:v2026.04.24-test3` with `repository name must be lowercase`. The workflow had normalized the registry host but not the repository path from `github.repository`. - **Applied fix for image naming:** lowercased `REPOSITORY_PATH` during image metadata normalization so image tags remain valid even when the Gitea owner or repository name contains uppercase letters. +- **Latest remote failure on act_runner:** a `*-test` run still reached `Notify Deploy Server` and failed with `curl: (3) URL using bad/illegal format or missing URL`. That showed the shell-level `IS_TEST_TAG` guard was not reliable enough for cross-step skip control on this runner. +- **Applied fix for test-tag skipping:** moved registry login and deploy webhook skipping to workflow-level `if:` conditions based on `endsWith(github.ref_name, '-test')`, and made the image-push branch check the tag name directly instead of relying on `IS_TEST_TAG` from a previous step. - **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. - **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From 3ba252462dbe9a389853c0905254a2f06ab628f4 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 15:59:58 +0800 Subject: [PATCH 105/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.gitignore?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=20memery.md=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0563835..06961ee 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ yarn-error.log* # typescript *.tsbuildinfo -next-env.d.ts \ No newline at end of file +next-env.d.ts +memery.md -- 2.54.0 From 46a4d7157dccc2810d46bee6c943f9868b2dc1a0 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 16:01:08 +0800 Subject: [PATCH 106/281] ci: harden test tag guards Use direct shell checks on github.ref_name inside workflow steps so test tags skip registry login, image push, and deploy webhook regardless of Gitea expression behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 26 ++++++++++++++++++++------ memery.md | 2 ++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index eef9682..f95d688 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -75,8 +75,14 @@ jobs: } >> "$GITHUB_ENV" - name: Login to Gitea Container Registry - if: ${{ !endsWith(github.ref_name, '-test') }} run: | + case "${{ github.ref_name }}" in + *-test) + echo "Test tag detected; skipping registry login." + exit 0 + ;; + esac + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \ --username "${{ secrets.REGISTRY_USERNAME }}" \ --password-stdin @@ -97,16 +103,24 @@ jobs: --build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \ --build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \ . - if [ "${{ endsWith(github.ref_name, '-test') }}" = "true" ]; then - echo "Test tag detected; build completed without pushing images." - exit 0 - fi + case "${{ github.ref_name }}" in + *-test) + echo "Test tag detected; build completed without pushing images." + exit 0 + ;; + esac docker push "${IMAGE_NAME}:${IMAGE_TAG}" docker push "${IMAGE_NAME}:latest" - name: Notify Deploy Server - if: ${{ success() && !endsWith(github.ref_name, '-test') }} run: | + case "${{ github.ref_name }}" in + *-test) + echo "Test tag detected; skipping deploy webhook." + exit 0 + ;; + esac + curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ diff --git a/memery.md b/memery.md index e93b1b8..de0ead5 100644 --- a/memery.md +++ b/memery.md @@ -13,5 +13,7 @@ - **Applied fix for image naming:** lowercased `REPOSITORY_PATH` during image metadata normalization so image tags remain valid even when the Gitea owner or repository name contains uppercase letters. - **Latest remote failure on act_runner:** a `*-test` run still reached `Notify Deploy Server` and failed with `curl: (3) URL using bad/illegal format or missing URL`. That showed the shell-level `IS_TEST_TAG` guard was not reliable enough for cross-step skip control on this runner. - **Applied fix for test-tag skipping:** moved registry login and deploy webhook skipping to workflow-level `if:` conditions based on `endsWith(github.ref_name, '-test')`, and made the image-push branch check the tag name directly instead of relying on `IS_TEST_TAG` from a previous step. +- **Follow-up from server validation:** the runner still executed `Notify Deploy Server` for `v2026.04.24-test5`, so Gitea step-level `if:` with `endsWith(...)` was not sufficient in this environment. +- **Applied hardening:** replaced those step-level conditions with direct shell `case "${{ github.ref_name }}" in *-test)` guards inside the login, push, and deploy steps. This avoids relying on Gitea expression behavior for test-tag skipping. - **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. - **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From 260c493fc8ca6ce93c2e81ce89579b812e3e6da4 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 16:05:08 +0800 Subject: [PATCH 107/281] ci: enable full cd path Remove test-tag bypass logic so release tags exercise registry login, image push, and deploy webhook end to end on Gitea Actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 25 ------------------------- memery.md | 1 + 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index f95d688..b28e96f 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -61,28 +61,16 @@ jobs: REPOSITORY_PATH="${RAW_REPOSITORY#/}" REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}" - case "$IMAGE_TAG" in - *-test) IS_TEST_TAG=true ;; - *) IS_TEST_TAG=false ;; - esac { echo "REGISTRY_HOST=${REGISTRY_HOST}" echo "REPOSITORY_PATH=${REPOSITORY_PATH}" echo "IMAGE_NAME=${IMAGE_NAME}" echo "IMAGE_TAG=${IMAGE_TAG}" echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}" - echo "IS_TEST_TAG=${IS_TEST_TAG}" } >> "$GITHUB_ENV" - name: Login to Gitea Container Registry run: | - case "${{ github.ref_name }}" in - *-test) - echo "Test tag detected; skipping registry login." - exit 0 - ;; - esac - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \ --username "${{ secrets.REGISTRY_USERNAME }}" \ --password-stdin @@ -103,24 +91,11 @@ jobs: --build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \ --build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \ . - case "${{ github.ref_name }}" in - *-test) - echo "Test tag detected; build completed without pushing images." - exit 0 - ;; - esac docker push "${IMAGE_NAME}:${IMAGE_TAG}" docker push "${IMAGE_NAME}:latest" - name: Notify Deploy Server run: | - case "${{ github.ref_name }}" in - *-test) - echo "Test tag detected; skipping deploy webhook." - exit 0 - ;; - esac - curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ diff --git a/memery.md b/memery.md index de0ead5..5a47dd0 100644 --- a/memery.md +++ b/memery.md @@ -15,5 +15,6 @@ - **Applied fix for test-tag skipping:** moved registry login and deploy webhook skipping to workflow-level `if:` conditions based on `endsWith(github.ref_name, '-test')`, and made the image-push branch check the tag name directly instead of relying on `IS_TEST_TAG` from a previous step. - **Follow-up from server validation:** the runner still executed `Notify Deploy Server` for `v2026.04.24-test5`, so Gitea step-level `if:` with `endsWith(...)` was not sufficient in this environment. - **Applied hardening:** replaced those step-level conditions with direct shell `case "${{ github.ref_name }}" in *-test)` guards inside the login, push, and deploy steps. This avoids relying on Gitea expression behavior for test-tag skipping. +- **Workflow mode changed for full CD verification:** per latest request, all `*-test` bypass logic was removed again so the workflow always runs registry login, image push, and deploy webhook. Full deployment validation now depends on using a normal `v*` tag and observing the real CD result instead of synthetic skip branches. - **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. - **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From a1442fc06201b0f356ed99ceb2cd38fff20d6cb7 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 24 Apr 2026 16:07:59 +0800 Subject: [PATCH 108/281] ci: retry registry pushes Retry Docker image pushes to the Gitea registry so transient EOF failures during blob upload do not fail the whole CD run on the first attempt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/package.yml | 24 ++++++++++++++++++++++-- memery.md | 2 ++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index b28e96f..639e848 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -77,6 +77,26 @@ jobs: - name: Build and Push Image run: | + push_with_retry() { + image_ref="$1" + attempt=1 + max_attempts=3 + + while [ "$attempt" -le "$max_attempts" ]; do + if docker push "$image_ref"; then + return 0 + fi + + if [ "$attempt" -eq "$max_attempts" ]; then + return 1 + fi + + echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..." + attempt=$((attempt + 1)) + sleep 10 + done + } + docker build \ -f ./Dockerfile \ -t "${IMAGE_NAME}:${IMAGE_TAG}" \ @@ -91,8 +111,8 @@ jobs: --build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \ --build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \ . - docker push "${IMAGE_NAME}:${IMAGE_TAG}" - docker push "${IMAGE_NAME}:latest" + push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}" + push_with_retry "${IMAGE_NAME}:latest" - name: Notify Deploy Server run: | diff --git a/memery.md b/memery.md index 5a47dd0..c19a0d4 100644 --- a/memery.md +++ b/memery.md @@ -16,5 +16,7 @@ - **Follow-up from server validation:** the runner still executed `Notify Deploy Server` for `v2026.04.24-test5`, so Gitea step-level `if:` with `endsWith(...)` was not sufficient in this environment. - **Applied hardening:** replaced those step-level conditions with direct shell `case "${{ github.ref_name }}" in *-test)` guards inside the login, push, and deploy steps. This avoids relying on Gitea expression behavior for test-tag skipping. - **Workflow mode changed for full CD verification:** per latest request, all `*-test` bypass logic was removed again so the workflow always runs registry login, image push, and deploy webhook. Full deployment validation now depends on using a normal `v*` tag and observing the real CD result instead of synthetic skip branches. +- **Next full-CD failure on act_runner:** image build completed, but pushing to the Gitea registry failed on blob upload commit with `failed to do request: Put ... EOF`. This is past the workflow logic stage and points to a transient or infrastructure-side registry upload failure. +- **Applied push hardening:** wrapped both `docker push "${IMAGE_NAME}:${IMAGE_TAG}"` and `docker push "${IMAGE_NAME}:latest"` in a 3-attempt retry helper with a short backoff to absorb transient registry EOF failures. - **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment. - **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow. -- 2.54.0 From 60181dba54ce1334d6c06ba6b7d85157ba4cda07 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 27 Apr 2026 11:56:56 +0800 Subject: [PATCH 109/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=B8=BA=E5=8F=AF=E6=8B=96=E5=8A=A8=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=A5=E5=85=B7=E6=A0=8F=E6=BF=80=E6=B4=BB?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/_refine_context.tsx | 2 +- src/components/olmap/SCADA/SCADADataPanel.tsx | 2 +- .../olmap/core/Controls/HistoryDataPanel.tsx | 133 ++++--- .../olmap/core/Controls/PropertyPanel.tsx | 341 +++++++++--------- .../olmap/core/Controls/Toolbar.tsx | 29 +- 5 files changed, 268 insertions(+), 239 deletions(-) diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index 9eece2b..e78741d 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -169,7 +169,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => { { name: "Hydraulic Simulation", meta: { - icon: <MdWater className="w-6 h-6" />, + // icon: <MdWater className="w-6 h-6" />, label: "事件模拟", }, }, diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index 3b6544c..1477e55 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -986,7 +986,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ <Box className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100" onClick={() => setIsExpanded(true)} - sx={{ zIndex: 1300 }} + sx={{ zIndex: 1290 }} > <Box className="flex flex-col items-center py-3 px-3 gap-1"> <ShowChart className="text-[#257DD4] w-5 h-5" /> diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index ae21ab5..340f41c 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -129,14 +129,17 @@ const fetchFromBackend = async ( "raw" ); } else if (type === "scheme") { - // 查询策略模拟值、清洗值和监测值 - const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([ + // 查询策略模拟值、实时模拟值、清洗值和监测值 + const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([ apiFetch(cleanedDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), apiFetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), + apiFetch(simulationDataUrl) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), apiFetch(schemeSimulationDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), @@ -146,40 +149,18 @@ const fetchFromBackend = async ( // 如果清洗数据有值,则不显示原始监测值 const rawData = cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); + const simulationData = transformBackendData(simulationRes, featureIds); const schemeSimData = transformBackendData(schemeSimRes, featureIds); - // 合并三组数据 - const timeMap = new Map<string, Record<string, number | null>>(); - - [cleanedData, rawData, schemeSimData].forEach((data, index) => { - const suffix = ["clean", "raw", "scheme_sim"][index]; - data.forEach((point) => { - if (!timeMap.has(point.timestamp)) { - timeMap.set(point.timestamp, {}); - } - const values = timeMap.get(point.timestamp)!; - featureIds.forEach((deviceId) => { - const value = point.values[deviceId]; - if (value !== undefined) { - values[`${deviceId}_${suffix}`] = value; - } - }); - }); - }); - - const result = Array.from(timeMap.entries()).map( - ([timestamp, values]) => ({ - timestamp, - values, - }) + return mergeMultipleTimeSeriesData( + [ + { data: cleanedData, suffix: "clean" }, + { data: rawData, suffix: "raw" }, + { data: simulationData, suffix: "sim" }, + { data: schemeSimData, suffix: "scheme_sim" }, + ], + featureIds ); - - result.sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - - return result; } else { // realtime: 查询模拟值、清洗值和监测值 const [cleanedRes, rawRes, simulationRes] = await Promise.all([ @@ -336,6 +317,42 @@ const mergeTimeSeriesData = ( return result; }; +const mergeMultipleTimeSeriesData = ( + datasets: Array<{ + data: TimeSeriesPoint[]; + suffix: string; + }>, + deviceIds: string[] +): TimeSeriesPoint[] => { + const timeMap = new Map<string, Record<string, number | null>>(); + + datasets.forEach(({ data, suffix }) => { + data.forEach((point) => { + if (!timeMap.has(point.timestamp)) { + timeMap.set(point.timestamp, {}); + } + const values = timeMap.get(point.timestamp)!; + deviceIds.forEach((deviceId) => { + const value = point.values[deviceId]; + if (value !== undefined) { + values[`${deviceId}_${suffix}`] = value; + } + }); + }); + }); + + const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({ + timestamp, + values, + })); + + result.sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + return result; +}; + const formatTimestamp = (timestamp: string) => dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm"); @@ -537,7 +554,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ const suffixes = [ { key: "clean", name: "清洗值" }, { key: "raw", name: "监测值" }, - { key: "sim", name: "模拟值" }, + { key: "sim", name: "实时模拟值" }, { key: "scheme_sim", name: "方案模拟值" }, ]; @@ -643,32 +660,46 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ : suffix === "raw" ? "监测值" : suffix === "sim" - ? "模拟" + ? "实时模拟" : "方案模拟"; series.push({ name: `${id} (${displayName})`, type: "line", - symbol: "none", + symbol: + suffix === "clean" + ? "circle" + : suffix === "raw" + ? "diamond" + : "none", + symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0, + showSymbol: suffix === "clean" || suffix === "raw", sampling: "lttb", - connectNulls: true, + connectNulls: suffix !== "clean" && suffix !== "raw", itemStyle: { color: colors[(index * 4 + sIndex) % colors.length], }, data: dataset.map((item) => item[key]), - areaStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { - offset: 0, - color: colors[(index * 4 + sIndex) % colors.length], - }, - { - offset: 1, - color: "rgba(255, 255, 255, 0)", - }, - ]), - opacity: 0.3, - }, + lineStyle: + suffix === "clean" || suffix === "raw" + ? { width: 0 } + : undefined, + areaStyle: + suffix === "clean" || suffix === "raw" + ? undefined + : { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { + offset: 0, + color: colors[(index * 4 + sIndex) % colors.length], + }, + { + offset: 1, + color: "rgba(255, 255, 255, 0)", + }, + ]), + opacity: 0.3, + }, }); } }); @@ -840,7 +871,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ border: "none", display: "flex", flexDirection: "column", - zIndex: 1300, + zIndex: 1290, backgroundColor: "white", overflow: "hidden", "&:hover": { diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index 8aa07e7..2cd4d89 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -1,4 +1,7 @@ -import React from "react"; +"use client"; + +import React, { useRef } from "react"; +import Draggable from "react-draggable"; interface BaseProperty { label: string; @@ -28,6 +31,8 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ type = "未知类型", properties = [], }) => { + const draggableRef = useRef<HTMLDivElement>(null); + const formatValue = (property: BaseProperty) => { if (property.formatter) { return property.formatter(property.value); @@ -50,162 +55,16 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ : 0; return ( - <div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 "> - {/* 头部 */} - <div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white"> - <div className="flex items-center gap-2"> - <svg - className="w-5 h-5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - /> - </svg> - <h3 className="text-lg font-semibold">属性面板</h3> - </div> - </div> - - {/* 内容区域 */} - <div className="flex-1 overflow-y-auto px-4 py-3"> - {!id ? ( - <div className="flex flex-col items-center justify-center py-12 text-gray-400"> + <Draggable nodeRef={draggableRef} handle=".drag-handle"> + <div + ref={draggableRef} + className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100" + > + {/* 头部 */} + <div className="drag-handle flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white cursor-move select-none"> + <div className="flex items-center gap-2"> <svg - className="w-16 h-16 mb-3" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={1.5} - d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" - /> - </svg> - <p className="text-sm">暂无属性信息</p> - <p className="text-xs mt-1">请选择一个要素以查看其属性</p> - </div> - ) : ( - <div className="space-y-2"> - {/* ID 属性 */} - <div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"> - <div className="flex justify-between items-start gap-3"> - <span className="font-medium text-xs uppercase tracking-wide text-blue-700"> - ID - </span> - <span className="text-sm font-semibold text-right flex-1 text-blue-900"> - {id} - </span> - </div> - </div> - - {/* 类型属性 */} - <div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"> - <div className="flex justify-between items-start gap-3"> - <span className="font-medium text-xs uppercase tracking-wide text-blue-700"> - 类型 - </span> - <span className="text-sm font-semibold text-right flex-1 text-blue-900"> - {type} - </span> - </div> - </div> - - {/* 其他属性(包含二级表格) */} - {properties.map((property, index) => { - // 二级表格 - if ("type" in property && property.type === "table") { - return ( - <div - key={`table-${index}`} - className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100" - > - <div className="flex justify-between items-start gap-3"> - <span className="font-medium text-xs uppercase tracking-wide text-gray-600"> - {property.label} - </span> - </div> - <div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm"> - <table className="w-full text-xs"> - <thead className="bg-gray-200 text-gray-700"> - <tr> - {property.columns.map((col, ci) => ( - <th - key={ci} - className="px-3 py-2 text-left font-semibold" - > - {col} - </th> - ))} - </tr> - </thead> - <tbody className="divide-y divide-gray-300"> - {property.rows.map((row, ri) => ( - <tr key={ri} className="bg-white hover:bg-gray-50"> - {row.map((cell, cci) => ( - <td - key={cci} - className="px-3 py-2 text-gray-800" - > - {cell} - </td> - ))} - </tr> - ))} - </tbody> - </table> - </div> - </div> - ); - } - - // 普通属性 - const base = property as BaseProperty; - const isImportant = isImportantKeys.includes(base.label); - return ( - <div - key={`prop-${index}`} - className={`group rounded-lg p-3 transition-all duration-200 ${ - isImportant - ? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500" - : "bg-gray-50 hover:bg-gray-100" - }`} - > - <div className="flex justify-between items-start gap-3"> - <span - className={`font-medium text-xs uppercase tracking-wide ${ - isImportant ? "text-blue-700" : "text-gray-600" - }`} - > - {base.label} - </span> - <span - className={`text-sm font-semibold text-right flex-1 ${ - isImportant ? "text-blue-900" : "text-gray-800" - }`} - > - {formatValue(base)} - </span> - </div> - </div> - ); - })} - </div> - )} - </div> - - {/* 底部统计区域 */} - <div className="px-5 py-3 bg-gray-50 border-t border-gray-200"> - <div className="flex items-center justify-between text-xs"> - <span className="text-gray-600 flex items-center gap-1"> - <svg - className="w-4 h-4" + className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" @@ -214,20 +73,172 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} - d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> </svg> - 共 {totalProps} 个属性 - </span> - {id && ( - <span className="text-green-600 flex items-center gap-1 font-medium"> - <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span> - 已选中 - </span> + <h3 className="text-lg font-semibold">属性面板</h3> + </div> + </div> + + {/* 内容区域 */} + <div className="flex-1 overflow-y-auto px-4 py-3"> + {!id ? ( + <div className="flex flex-col items-center justify-center py-12 text-gray-400"> + <svg + className="w-16 h-16 mb-3" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" + /> + </svg> + <p className="text-sm">暂无属性信息</p> + <p className="text-xs mt-1">请选择一个要素以查看其属性</p> + </div> + ) : ( + <div className="space-y-2"> + {/* ID 属性 */} + <div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"> + <div className="flex justify-between items-start gap-3"> + <span className="font-medium text-xs uppercase tracking-wide text-blue-700"> + ID + </span> + <span className="text-sm font-semibold text-right flex-1 text-blue-900"> + {id} + </span> + </div> + </div> + + {/* 类型属性 */} + <div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"> + <div className="flex justify-between items-start gap-3"> + <span className="font-medium text-xs uppercase tracking-wide text-blue-700"> + 类型 + </span> + <span className="text-sm font-semibold text-right flex-1 text-blue-900"> + {type} + </span> + </div> + </div> + + {/* 其他属性(包含二级表格) */} + {properties.map((property, index) => { + // 二级表格 + if ("type" in property && property.type === "table") { + return ( + <div + key={`table-${index}`} + className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100" + > + <div className="flex justify-between items-start gap-3"> + <span className="font-medium text-xs uppercase tracking-wide text-gray-600"> + {property.label} + </span> + </div> + <div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm"> + <table className="w-full text-xs"> + <thead className="bg-gray-200 text-gray-700"> + <tr> + {property.columns.map((col, ci) => ( + <th + key={ci} + className="px-3 py-2 text-left font-semibold" + > + {col} + </th> + ))} + </tr> + </thead> + <tbody className="divide-y divide-gray-300"> + {property.rows.map((row, ri) => ( + <tr key={ri} className="bg-white hover:bg-gray-50"> + {row.map((cell, cci) => ( + <td + key={cci} + className="px-3 py-2 text-gray-800" + > + {cell} + </td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + </div> + ); + } + + // 普通属性 + const base = property as BaseProperty; + const isImportant = isImportantKeys.includes(base.label); + return ( + <div + key={`prop-${index}`} + className={`group rounded-lg p-3 transition-all duration-200 ${ + isImportant + ? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500" + : "bg-gray-50 hover:bg-gray-100" + }`} + > + <div className="flex justify-between items-start gap-3"> + <span + className={`font-medium text-xs uppercase tracking-wide ${ + isImportant ? "text-blue-700" : "text-gray-600" + }`} + > + {base.label} + </span> + <span + className={`text-sm font-semibold text-right flex-1 ${ + isImportant ? "text-blue-900" : "text-gray-800" + }`} + > + {formatValue(base)} + </span> + </div> + </div> + ); + })} + </div> )} + + </div> + + {/* 底部统计区域 */} + <div className="px-5 py-3 bg-gray-50 border-t border-gray-200"> + <div className="flex items-center justify-between text-xs"> + <span className="text-gray-600 flex items-center gap-1"> + <svg + className="w-4 h-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" + /> + </svg> + 共 {totalProps} 个属性 + </span> + {id && ( + <span className="text-green-600 flex items-center gap-1 font-medium"> + <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span> + 已选中 + </span> + )} + </div> </div> </div> - </div> + </Draggable> ); }; diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 1e7d2f8..3466c77 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -402,15 +402,8 @@ const Toolbar: React.FC<ToolbarProps> = ({ deactivateTool(tool); setActiveTools((prev) => prev.filter((t) => t !== tool)); } else { - // 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具 - // 关闭所有面板(但保持样式编辑器状态) - closeAllPanelsExceptStyle(); - - // 取消激活所有非样式工具 - setActiveTools((prev) => { - const styleActive = prev.includes("style"); - return styleActive ? ["style", tool] : [tool]; - }); + // 如果当前工具未激活,保留其他已打开工具,仅新增当前工具 + setActiveTools((prev) => [...prev, tool]); // 激活当前工具并打开对应面板 activateTool(tool); @@ -422,14 +415,18 @@ const Toolbar: React.FC<ToolbarProps> = ({ switch (tool) { case "info": setShowPropertyPanel(false); - setHighlightFeatures([]); + if (!activeTools.includes("history")) { + setHighlightFeatures([]); + } break; case "draw": setShowDrawPanel(false); break; case "history": setShowHistoryPanel(false); - setHighlightFeatures([]); + if (!activeTools.includes("info")) { + setHighlightFeatures([]); + } setChatPanelFeatureInfos(null); setChatPanelTimeRange(null); break; @@ -452,16 +449,6 @@ const Toolbar: React.FC<ToolbarProps> = ({ } }; - // 关闭所有面板(除了样式编辑器) - const closeAllPanelsExceptStyle = () => { - setShowPropertyPanel(false); - setHighlightFeatures([]); - setShowDrawPanel(false); - setShowHistoryPanel(false); - setChatPanelFeatureInfos(null); - setChatPanelTimeRange(null); - // 样式编辑器保持其当前状态,不自动关闭 - }; const [computedProperties, setComputedProperties] = useState< Record<string, any> >({}); -- 2.54.0 From 07861bee0386d437188ba337eba7db862efbf942 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 27 Apr 2026 15:59:49 +0800 Subject: [PATCH 110/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../burst-simulation/page.tsx | 6 +- .../contaminant-simulation/page.tsx | 6 +- .../olmap/core/Controls/BaseLayers.tsx | 269 +++--- .../olmap/core/Controls/LayerControl.tsx | 15 +- .../olmap/core/Controls/StyleEditorPanel.tsx | 183 +++-- .../olmap/core/Controls/Timeline.tsx | 294 ++++--- .../olmap/core/Controls/Toolbar.tsx | 23 + src/components/olmap/core/MapComponent.tsx | 766 ++++++++++++++---- 8 files changed, 1123 insertions(+), 439 deletions(-) diff --git a/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx index 8078cf7..86f7864 100644 --- a/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx +++ b/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx @@ -8,7 +8,11 @@ export default function Home() { return ( <div className="relative w-full h-full overflow-hidden"> <MapComponent> - <MapToolbar queryType="scheme" schemeType="burst_analysis" /> + <MapToolbar + queryType="scheme" + schemeType="burst_analysis" + enableCompare + /> <BurstPipeAnalysisPanel /> </MapComponent> </div> diff --git a/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx index 2c3d499..265c631 100644 --- a/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx +++ b/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx @@ -8,7 +8,11 @@ export default function Home() { return ( <div className="relative w-full h-full overflow-hidden"> <MapComponent> - <MapToolbar queryType="scheme" schemeType="contaminant_analysis" /> + <MapToolbar + queryType="scheme" + schemeType="contaminant_analysis" + enableCompare + /> <WaterQualityPanel /> </MapComponent> </div> diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 7a6c09a..aa43007 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -1,158 +1,174 @@ -import React, { useState, useEffect } from "react"; +"use client"; + +import React, { useState, useEffect, useMemo, useRef } from "react"; import Image from "next/image"; -import { useMap } from "../MapComponent"; +import { useData, useMap } from "../MapComponent"; import TileLayer from "ol/layer/Tile.js"; import XYZ from "ol/source/XYZ.js"; +import Group from "ol/layer/Group"; import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png"; import mapboxLight from "@assets/map/layers/mapbox-light.png"; import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png"; import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png"; import mapboxStreets from "@assets/map/layers/mapbox-streets.png"; import clsx from "clsx"; -import Group from "ol/layer/Group"; -import { MAPBOX_TOKEN } from "@config/config"; -import { TIANDITU_TOKEN } from "@config/config"; +import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config"; +import type { Map as OlMap } from "ol"; + const INITIAL_LAYER = "mapbox-light"; -const streetsLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', - }), -}); -const lightMapLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', - }), -}); -const satelliteLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', - }), -}); -const satelliteStreetsLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', - }), -}); +const createTileLayer = (url: string, attributions: string) => + new TileLayer({ + source: new XYZ({ + url, + tileSize: 512, + maxZoom: 20, + projection: "EPSG:3857", + attributions, + }), + }); -const tiandituVectorLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), -}); -const tiandituVectorAnnotationLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), -}); -const tiandituImageLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), -}); -const tiandituImageAnnotationLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), -}); -const tiandituVectorLayerGroup = new Group({ - layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], -}); -const tiandituImageLayerGroup = new Group({ - layers: [tiandituImageLayer, tiandituImageAnnotationLayer], -}); -const baseLayers = [ - { - id: "mapbox-light", - name: "默认地图", - layer: lightMapLayer, - // layer: tiandituVectorLayerGroup, - img: mapboxLight.src, - }, - { - id: "mapbox-satellite", - name: "卫星地图", - layer: satelliteLayer, - // layer: tiandituImageLayerGroup, - img: mapboxSatellite.src, - }, - { - id: "mapbox-satellite-streets", - name: "卫星街道地图", - layer: satelliteStreetsLayer, - img: mapboxSatelliteStreet.src, - }, - { - id: "mapbox-streets", - name: "街道地图", - layer: streetsLayer, - img: mapboxStreets.src, - }, -]; +const createBaseLayerEntries = () => { + const streetsLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' + ); + const lightMapLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' + ); + const satelliteLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' + ); + const satelliteStreetsLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' + ); + + const tiandituVectorLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', + }), + }); + const tiandituVectorAnnotationLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', + }), + }); + const tiandituImageLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', + }), + }); + const tiandituImageAnnotationLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', + }), + }); + + return [ + { + id: "mapbox-light", + name: "默认地图", + layer: lightMapLayer, + img: mapboxLight.src, + }, + { + id: "mapbox-satellite", + name: "卫星地图", + layer: satelliteLayer, + img: mapboxSatellite.src, + }, + { + id: "mapbox-satellite-streets", + name: "卫星街道地图", + layer: satelliteStreetsLayer, + img: mapboxSatelliteStreet.src, + }, + { + id: "mapbox-streets", + name: "街道地图", + layer: streetsLayer, + img: mapboxStreets.src, + }, + { + id: "tianditu-vector", + name: "天地图矢量", + layer: new Group({ + layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], + }), + img: mapboxOutdoors.src, + }, + { + id: "tianditu-image", + name: "天地图影像", + layer: new Group({ + layers: [tiandituImageLayer, tiandituImageAnnotationLayer], + }), + img: mapboxSatellite.src, + }, + ]; +}; const BaseLayers: React.FC = () => { const map = useMap(); - // 切换底图选项展开,控制显示和卸载 + const data = useData(); + const maps = useMemo(() => { + if (data?.maps?.length) return data.maps; + return map ? [map] : []; + }, [data?.maps, map]); + const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>()); const [isShow, setShow] = useState(false); const [isExpanded, setExpanded] = useState(false); - // 快速切换底图 const [activeId, setActiveId] = useState(INITIAL_LAYER); - // 初始化默认底图 useEffect(() => { - if (!map) return; - // 添加所有底图至地图并根据 activeId 控制可见性 - baseLayers.forEach((layerInfo) => { - const layers = map.getLayers().getArray(); - if (!layers.includes(layerInfo.layer)) { - map.getLayers().insertAt(0, layerInfo.layer); + maps.forEach((targetMap) => { + let layerEntries = layerSetsRef.current.get(targetMap); + if (!layerEntries) { + layerEntries = createBaseLayerEntries(); + layerSetsRef.current.set(targetMap, layerEntries); } - layerInfo.layer.setVisible(layerInfo.id === activeId); + + layerEntries.forEach((layerInfo) => { + const layers = targetMap.getLayers().getArray(); + if (!layers.includes(layerInfo.layer)) { + targetMap.getLayers().insertAt(0, layerInfo.layer); + } + layerInfo.layer.setVisible(layerInfo.id === activeId); + }); }); - }, [map, activeId]); + }, [activeId, maps]); const changeMapLayers = (id: string) => { - if (map) { - // 根据 id 设置每个图层的可见性 - baseLayers.forEach(({ id: lid, layer }) => { - layer.setVisible(lid === id); + maps.forEach((targetMap) => { + const layerEntries = layerSetsRef.current.get(targetMap); + layerEntries?.forEach(({ id: layerId, layer }) => { + layer.setVisible(layerId === id); }); - } + }); }; + const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({ + id, + name, + img, + })), []); + const handleQuickSwitch = () => { const nextId = activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id; setActiveId(nextId); - handleMapLayers(nextId); + changeMapLayers(nextId); }; const handleMapLayers = (id: string) => { @@ -160,7 +176,6 @@ const BaseLayers: React.FC = () => { changeMapLayers(id); }; - // 记录定时器,避免多次触发 const hideTimer = React.useRef<NodeJS.Timeout | null>(null); const handleEnter = () => { @@ -217,7 +232,7 @@ const BaseLayers: React.FC = () => { {isExpanded && ( <div className={clsx( - "absolute flex right-24 bottom-0 w-90 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300", + "absolute flex right-24 bottom-0 w-132 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300", isShow ? "opacity-100" : "opacity-0" )} onMouseEnter={handleEnter} @@ -226,7 +241,7 @@ const BaseLayers: React.FC = () => { {baseLayers.map((item) => ( <button key={item.id} - className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs" + className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs" onClick={() => handleMapLayers(item.id)} > <Image diff --git a/src/components/olmap/core/Controls/LayerControl.tsx b/src/components/olmap/core/Controls/LayerControl.tsx index 50372a3..6796d4d 100644 --- a/src/components/olmap/core/Controls/LayerControl.tsx +++ b/src/components/olmap/core/Controls/LayerControl.tsx @@ -5,6 +5,7 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import VectorLayer from "ol/layer/Vector"; import VectorTileLayer from "ol/layer/VectorTile"; import { DeckLayer } from "@utils/layers"; +import type { Map as OlMap } from "ol"; // 定义统一的图层项接口 interface LayerItem { @@ -30,8 +31,10 @@ const LAYER_ORDER = [ const LayerControl: React.FC = () => { const map = useMap(); const data = useData(); + const maps: OlMap[] = data?.maps?.length ? data.maps : map ? [map] : []; const [refreshKey, setRefreshKey] = useState(0); const deckLayer = data?.deckLayer; + const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []); const isContourLayerAvailable = data?.isContourLayerAvailable; const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable; const setShowWaterflowLayer = data?.setShowWaterflowLayer; @@ -117,8 +120,16 @@ const LayerControl: React.FC = () => { const handleVisibilityChange = (item: LayerItem, checked: boolean) => { if (item.type === "ol") { - item.layerRef.setVisible(checked); - } else if (item.type === "deck" && deckLayer) { + maps.forEach((targetMap) => { + targetMap + .getAllLayers() + .filter((layer) => layer.get("value") === item.id) + .forEach((layer) => layer.setVisible(checked)); + }); + } else if (item.type === "deck" && deckLayers.length > 0) { + deckLayers.forEach((targetDeckLayer) => { + targetDeckLayer.setDeckLayerVisible(item.id, checked); + }); if (item.id === "junctionContourLayer") { setShowContourLayer && setShowContourLayer(checked); } diff --git a/src/components/olmap/core/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx index ebafaba..8336655 100644 --- a/src/components/olmap/core/Controls/StyleEditorPanel.tsx +++ b/src/components/olmap/core/Controls/StyleEditorPanel.tsx @@ -29,6 +29,7 @@ import { FlatStyleLike } from "ol/style/flat"; import { calculateClassification } from "@utils/breaks_classification"; import { parseColor } from "@utils/parseColor"; import { VectorTile } from "ol"; +import type { Map as OlMap } from "ol"; import { useNotification } from "@refinedev/core"; import { config } from "@/config/config"; @@ -182,6 +183,13 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ const data = useData(); const currentJunctionCalData = data?.currentJunctionCalData; const currentPipeCalData = data?.currentPipeCalData; + const compareJunctionCalData = data?.compareJunctionCalData; + const comparePipeCalData = data?.comparePipeCalData; + const compareMap = data?.compareMap; + const activeMaps = useMemo<OlMap[]>( + () => (data?.maps?.length ? data.maps : map ? [map] : []), + [data?.maps, map] + ); const junctionText = data?.junctionText ?? ""; const pipeText = data?.pipeText ?? ""; const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; @@ -229,6 +237,45 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ customColors: [], }); + const getRenderLayersById = useCallback( + (layerId: string) => + activeMaps.flatMap((targetMap) => + targetMap + .getAllLayers() + .filter((layer) => layer.get("value") === layerId) + .filter((layer): layer is WebGLVectorTileLayer => layer instanceof WebGLVectorTileLayer) + ), + [activeMaps] + ); + + const getMapKey = useCallback((targetMap: OlMap, layerId: string) => { + const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map"; + return `${mapUid}:${layerId}`; + }, []); + + const getDataForMap = useCallback( + (targetMap: OlMap, layerId: string) => { + if (layerId === "junctions") { + return targetMap === compareMap + ? compareJunctionCalData || [] + : currentJunctionCalData || []; + } + if (layerId === "pipes") { + return targetMap === compareMap + ? comparePipeCalData || [] + : currentPipeCalData || []; + } + return []; + }, + [ + compareJunctionCalData, + compareMap, + comparePipeCalData, + currentJunctionCalData, + currentPipeCalData, + ] + ); + const getDefaultCustomColors = ( segments: number, existingColors: string[] = [] @@ -613,13 +660,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ return; } const styleConfig = layerStyleConfig.styleConfig; - const renderLayer = renderLayers.filter((layer) => { - return layer.get("value") === layerStyleConfig.layerId; - })[0]; + const targetLayers = getRenderLayersById(layerStyleConfig.layerId); + const renderLayer = targetLayers[0]; if (!renderLayer || !styleConfig?.property) return; - const layerType: string = renderLayer?.get("type"); - const source = renderLayer.getSource(); - if (!source) return; + const layerType: string = renderLayer.get("type"); const breaksLength = breaks.length; // 根据 breaks 计算每个分段的颜色,线条粗细 @@ -757,7 +801,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ dynamicStyle["circle-stroke-width"] = 2; } // 应用样式到图层 - renderLayer.setStyle(dynamicStyle); + targetLayers.forEach((targetLayer) => { + targetLayer.setStyle(dynamicStyle); + }); // 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性 const layerId = renderLayer.get("value"); const initLayerStyleState = layerStyleStates.find( @@ -844,10 +890,12 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ if (!selectedRenderLayer) return; // 重置 WebGL 图层样式 const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; - selectedRenderLayer.setStyle(defaultFlatStyle); + const layerId = selectedRenderLayer.get("value"); + getRenderLayersById(layerId).forEach((targetLayer) => { + targetLayer.setStyle(defaultFlatStyle); + }); // 删除对应图层的样式状态,从而移除图例显示 - const layerId = selectedRenderLayer.get("value"); if (layerId !== undefined) { setLayerStyleStates((prev) => prev.filter((state) => state.layerId !== layerId) @@ -870,11 +918,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ } }; // 更新当前 VectorTileSource 中的所有缓冲要素属性 - const updateVectorTileSource = (property: string, data: any[]) => { - if (!map) return; - const vectorTileSources = map + const updateVectorTileSource = ( + targetMap: OlMap, + layerId: string, + property: string, + data: any[] + ) => { + const vectorTileSources = targetMap .getAllLayers() - .filter((layer) => layer instanceof WebGLVectorTileLayer) + .filter((layer) => layer.get("value") === layerId) .map((layer) => layer.getSource() as VectorTileSource) .filter((source) => source); @@ -911,16 +963,16 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ }; // 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性 const tileLoadListenersRef = useRef< - Map<VectorTileSource, (event: any) => void> + Map<string, { source: VectorTileSource; listener: (event: any) => void }> >(new Map()); const attachVectorTileSourceLoadedEvent = ( + targetMap: OlMap, layerId: string, property: string, data: any[] ) => { - if (!map) return; - const vectorTileSource = map + const vectorTileSource = targetMap .getAllLayers() .filter((layer) => layer.get("value") === layerId) .map((layer) => layer.getSource() as VectorTileSource) @@ -956,24 +1008,25 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ } }; + const listenerKey = getMapKey(targetMap, layerId); vectorTileSource.on("tileloadend", listener); - tileLoadListenersRef.current.set(vectorTileSource, listener); + tileLoadListenersRef.current.set(listenerKey, { + source: vectorTileSource, + listener, + }); }; // 新增函数:取消对应 layerId 已添加的 on 事件 - const removeVectorTileSourceLoadedEvent = (layerId: string) => { - if (!map) return; - const vectorTileSource = map - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source)[0]; - if (!vectorTileSource) return; - const listener = tileLoadListenersRef.current.get(vectorTileSource); - if (listener) { - vectorTileSource.un("tileloadend", listener); - tileLoadListenersRef.current.delete(vectorTileSource); - } - }; + const removeVectorTileSourceLoadedEvent = useCallback( + (targetMap: OlMap, layerId: string) => { + const listenerKey = getMapKey(targetMap, layerId); + const listenerState = tileLoadListenersRef.current.get(listenerKey); + if (listenerState) { + listenerState.source.un("tileloadend", listenerState.listener); + tileLoadListenersRef.current.delete(listenerKey); + } + }, + [getMapKey] + ); // 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发 useEffect(() => { @@ -998,20 +1051,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ ); if (isElevation) { - removeVectorTileSourceLoadedEvent("junctions"); + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + }); return; } - if (!currentJunctionCalData) return; - // 更新现有的 VectorTileSource - updateVectorTileSource(junctionText, currentJunctionCalData); - // 移除旧的监听器,并添加新的监听器 - removeVectorTileSourceLoadedEvent("junctions"); - attachVectorTileSourceLoadedEvent( - "junctions", - junctionText, - currentJunctionCalData - ); + activeMaps.forEach((targetMap) => { + const targetData = getDataForMap(targetMap, "junctions"); + if (!targetData || targetData.length === 0) return; + updateVectorTileSource(targetMap, "junctions", junctionText, targetData); + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + attachVectorTileSourceLoadedEvent( + targetMap, + "junctions", + junctionText, + targetData + ); + }); }; const updatePipeStyle = () => { const pipeStyleConfigState = layerStyleStates.find( @@ -1023,16 +1080,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig); if (isDiameter) { - removeVectorTileSourceLoadedEvent("pipes"); + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + }); return; } - if (!currentPipeCalData) return; - // 更新现有的 VectorTileSource - updateVectorTileSource(pipeText, currentPipeCalData); - // 移除旧的监听器,并添加新的监听器 - removeVectorTileSourceLoadedEvent("pipes"); - attachVectorTileSourceLoadedEvent("pipes", pipeText, currentPipeCalData); + activeMaps.forEach((targetMap) => { + const targetData = getDataForMap(targetMap, "pipes"); + if (!targetData || targetData.length === 0) return; + updateVectorTileSource(targetMap, "pipes", pipeText, targetData); + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + attachVectorTileSourceLoadedEvent( + targetMap, + "pipes", + pipeText, + targetData + ); + }); }; if (isUserTrigger) { if (selectedRenderLayer?.get("value") === "junctions") { @@ -1060,10 +1125,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ updatePipeStyle(); } if (!applyJunctionStyle) { - removeVectorTileSourceLoadedEvent("junctions"); + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + }); } if (!applyPipeStyle) { - removeVectorTileSourceLoadedEvent("pipes"); + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + }); } // This effect is intentionally driven by explicit style triggers and data snapshots. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1073,8 +1142,20 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ applyPipeStyle, currentJunctionCalData, currentPipeCalData, + compareJunctionCalData, + comparePipeCalData, + activeMaps, ]); + useEffect(() => { + return () => { + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + }); + }; + }, [activeMaps, removeVectorTileSourceLoadedEvent]); + // 获取地图中的矢量图层,用于选择图层选项 useEffect(() => { if (!map) return; diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index 5497b9a..3fe54da 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -63,6 +63,9 @@ const Timeline: React.FC<TimelineProps> = ({ const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE; const setCurrentJunctionCalData = data?.setCurrentJunctionCalData; const setCurrentPipeCalData = data?.setCurrentPipeCalData; + const setCompareJunctionCalData = data?.setCompareJunctionCalData; + const setComparePipeCalData = data?.setComparePipeCalData; + const isCompareMode = data?.isCompareMode ?? false; const junctionText = data?.junctionText ?? ""; const pipeText = data?.pipeText ?? ""; const { open } = useNotification(); @@ -94,100 +97,209 @@ const Timeline: React.FC<TimelineProps> = ({ // 添加防抖引用 const debounceRef = useRef<NodeJS.Timeout | null>(null); - const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => { - if (setCurrentJunctionCalData) { - setCurrentJunctionCalData(nodeResults); - } else { - console.log("setCurrentJunctionCalData is undefined"); - } - if (setCurrentPipeCalData) { - setCurrentPipeCalData(linkResults); - } else { - console.log("setCurrentPipeCalData is undefined"); - } - }, [setCurrentJunctionCalData, setCurrentPipeCalData]); + const updateDataStates = useCallback( + ( + nodeResults: any[], + linkResults: any[], + target: "primary" | "compare" = "primary" + ) => { + const setNodeData = + target === "compare" + ? setCompareJunctionCalData + : setCurrentJunctionCalData; + const setLinkData = + target === "compare" ? setComparePipeCalData : setCurrentPipeCalData; - const fetchFrameData = useCallback(async ( - queryTime: Date, - junctionProperties: string, - pipeProperties: string, - schemeName: string, - schemeType: string, - ) => { - const query_time = queryTime.toISOString(); - let nodeRecords: any = { results: [] }; - let linkRecords: any = { results: [] }; - const requests: Promise<Response>[] = []; - let nodePromise: Promise<any> | null = null; - let linkPromise: Promise<any> | null = null; - // 检查node缓存 - if (junctionProperties !== "" && junctionProperties !== "elevation") { - const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`; - if (nodeCacheRef.current.has(nodeCacheKey)) { - nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!; - } else { - disableDateSelection && schemeName - ? (nodePromise = apiFetch( - // `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}` - `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`, - )) - : (nodePromise = apiFetch( - // `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}` - `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`, - )); - requests.push(nodePromise); + setNodeData?.(nodeResults); + setLinkData?.(linkResults); + }, + [ + setCompareJunctionCalData, + setComparePipeCalData, + setCurrentJunctionCalData, + setCurrentPipeCalData, + ] + ); + + const buildCacheKey = useCallback( + ( + queryTime: string, + property: string, + sourceType: "scheme" | "realtime", + resultType: "node" | "link", + targetSchemeName: string, + targetSchemeType: string + ) => + [ + queryTime, + sourceType, + resultType, + property, + targetSchemeName || "default", + targetSchemeType || "default", + ].join("::"), + [] + ); + + const fetchDataBySource = useCallback( + async ({ + queryTime, + junctionProperties, + pipeProperties, + sourceType, + target, + schemeName, + schemeType, + }: { + queryTime: Date; + junctionProperties: string; + pipeProperties: string; + sourceType: "scheme" | "realtime"; + target: "primary" | "compare"; + schemeName?: string; + schemeType?: string; + }) => { + const query_time = queryTime.toISOString(); + let nodeRecords: any = { results: [] }; + let linkRecords: any = { results: [] }; + const requests: Promise<Response>[] = []; + let nodePromise: Promise<Response> | null = null; + let linkPromise: Promise<Response> | null = null; + + if (junctionProperties !== "" && junctionProperties !== "elevation") { + const nodeCacheKey = buildCacheKey( + query_time, + junctionProperties, + sourceType, + "node", + schemeName || "", + schemeType || "" + ); + if (nodeCacheRef.current.has(nodeCacheKey)) { + nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!; + } else { + nodePromise = + sourceType === "scheme" && schemeName + ? apiFetch( + `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}` + ) + : apiFetch( + `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}` + ); + requests.push(nodePromise); + } } - } - // 处理特殊属性名称 - if (pipeProperties === "unit_headloss") pipeProperties = "headloss"; - // 检查link缓存 - if (pipeProperties !== "" && pipeProperties !== "diameter") { - const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`; - if (linkCacheRef.current.has(linkCacheKey)) { - linkRecords = linkCacheRef.current.get(linkCacheKey)!; - } else { - disableDateSelection && schemeName - ? (linkPromise = apiFetch( - // `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}` - `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`, - )) - : (linkPromise = apiFetch( - // `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}` - `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`, - )); - requests.push(linkPromise); + const normalizedPipeProperties = + pipeProperties === "unit_headloss" ? "headloss" : pipeProperties; + + if (normalizedPipeProperties !== "" && normalizedPipeProperties !== "diameter") { + const linkCacheKey = buildCacheKey( + query_time, + normalizedPipeProperties, + sourceType, + "link", + schemeName || "", + schemeType || "" + ); + if (linkCacheRef.current.has(linkCacheKey)) { + linkRecords = linkCacheRef.current.get(linkCacheKey)!; + } else { + linkPromise = + sourceType === "scheme" && schemeName + ? apiFetch( + `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}` + ) + : apiFetch( + `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}` + ); + requests.push(linkPromise); + } } - } - // 等待所有有效请求 - const responses = await Promise.all(requests); + const responses = await Promise.all(requests); - if (nodePromise) { - const nodeResponse = responses.shift()!; - if (!nodeResponse.ok) - throw new Error(`Node fetch failed: ${nodeResponse.status}`); - nodeRecords = await nodeResponse.json(); - // 缓存数据(修复键以包含 schemeName) - nodeCacheRef.current.set( - `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`, - nodeRecords || [], - ); - } - if (linkPromise) { - const linkResponse = responses.shift()!; - if (!linkResponse.ok) - throw new Error(`Link fetch failed: ${linkResponse.status}`); - linkRecords = await linkResponse.json(); - // 缓存数据(修复键以包含 schemeName) - linkCacheRef.current.set( - `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`, - linkRecords || [], - ); - } - // 更新状态 - updateDataStates(nodeRecords.results || [], linkRecords.results || []); - }, [disableDateSelection, updateDataStates]); + if (nodePromise) { + const nodeResponse = responses.shift()!; + if (!nodeResponse.ok) { + throw new Error(`Node fetch failed: ${nodeResponse.status}`); + } + nodeRecords = await nodeResponse.json(); + nodeCacheRef.current.set( + buildCacheKey( + query_time, + junctionProperties, + sourceType, + "node", + schemeName || "", + schemeType || "" + ), + nodeRecords || [] + ); + } + + if (linkPromise) { + const linkResponse = responses.shift()!; + if (!linkResponse.ok) { + throw new Error(`Link fetch failed: ${linkResponse.status}`); + } + linkRecords = await linkResponse.json(); + linkCacheRef.current.set( + buildCacheKey( + query_time, + normalizedPipeProperties, + sourceType, + "link", + schemeName || "", + schemeType || "" + ), + linkRecords || [] + ); + } + + updateDataStates(nodeRecords.results || [], linkRecords.results || [], target); + }, + [buildCacheKey, updateDataStates] + ); + + const fetchFrameData = useCallback( + async ( + queryTime: Date, + junctionProperties: string, + pipeProperties: string, + schemeName: string, + schemeType: string + ) => { + const primarySourceType = + disableDateSelection && schemeName ? "scheme" : "realtime"; + const tasks = [ + fetchDataBySource({ + queryTime, + junctionProperties, + pipeProperties, + sourceType: primarySourceType, + target: "primary", + schemeName, + schemeType, + }), + ]; + + if (isCompareMode && disableDateSelection && schemeName) { + tasks.push( + fetchDataBySource({ + queryTime, + junctionProperties, + pipeProperties, + sourceType: "realtime", + target: "compare", + }) + ); + } + + await Promise.all(tasks); + }, + [disableDateSelection, fetchDataBySource, isCompareMode] + ); // 时间刻度数组 (每5分钟一个刻度) const timeMarks = Array.from({ length: 288 }, (_, i) => ({ @@ -453,9 +565,9 @@ const Timeline: React.FC<TimelineProps> = ({ if (!cacheRef.current) return; const cacheKeys = Array.from(cacheRef.current.keys()); cacheKeys.forEach((key) => { - const keyParts = key.split("_"); - const cacheDate = keyParts[0].split("T")[0]; - const cacheTimeStr = keyParts[0].split("T")[1]; + const cacheTimeKey = key.split("::")[0]; + const cacheDate = cacheTimeKey.split("T")[0]; + const cacheTimeStr = cacheTimeKey.split("T")[1]; if (cacheDate === dateStr && cacheTimeStr) { const [hours, minutes] = cacheTimeStr.split(":"); diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 3466c77..4a5e07d 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -5,6 +5,7 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined"; import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined"; +import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined"; import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件 import DrawPanel from "./DrawPanel"; // 引入绘图面板组件 import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件 @@ -34,12 +35,14 @@ interface ToolbarProps { queryType?: string; // 可选的查询类型参数 schemeType?: string; // 可选的方案类型参数 HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板 + enableCompare?: boolean; } const Toolbar: React.FC<ToolbarProps> = ({ hiddenButtons, queryType, schemeType, HistoryPanel, + enableCompare = false, }) => { const map = useMap(); const data = useData(); @@ -55,6 +58,17 @@ const Toolbar: React.FC<ToolbarProps> = ({ const currentTime = data?.currentTime; const selectedDate = data?.selectedDate; const schemeName = data?.schemeName; + const isCompareMode = data?.isCompareMode ?? false; + const toggleCompareMode = data?.toggleCompareMode; + const canToggleCompare = Boolean( + enableCompare && (isCompareMode || (queryType === "scheme" && schemeName)), + ); + + useEffect(() => { + if (!enableCompare && isCompareMode) { + toggleCompareMode?.(); + } + }, [enableCompare, isCompareMode, toggleCompareMode]); // Chat tool action → direct featureInfos override (bypasses OL Feature lookup) const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState< @@ -853,6 +867,15 @@ const Toolbar: React.FC<ToolbarProps> = ({ onClick={() => handleToolClick("style")} /> )} + {enableCompare && ( + <ToolbarButton + icon={<CompareArrowsOutlinedIcon />} + name={isCompareMode ? "关闭对比" : "双屏对比"} + isActive={isCompareMode} + onClick={() => toggleCompareMode?.()} + disabled={!canToggleCompare} + /> + )} </div> {showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />} {showDrawPanel && map && <DrawPanel />} diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 2fa0abb..546ac49 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect, useMemo, + useCallback, useRef, } from "react"; import { Map as OlMap, VectorTile } from "ol"; @@ -49,6 +50,13 @@ interface DataContextType { setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>; currentPipeCalData?: any[]; // 当前计算结果 setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>; + compareJunctionCalData?: any[]; + setCompareJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>; + comparePipeCalData?: any[]; + setComparePipeCalData?: React.Dispatch<React.SetStateAction<any[]>>; + isCompareMode?: boolean; + setCompareMode?: React.Dispatch<React.SetStateAction<boolean>>; + toggleCompareMode?: () => void; showJunctionText?: boolean; // 是否显示节点文本 showPipeText?: boolean; // 是否显示管道文本 showJunctionId?: boolean; // 是否显示节点ID @@ -69,6 +77,10 @@ interface DataContextType { setPipeText?: React.Dispatch<React.SetStateAction<string>>; setContours?: React.Dispatch<React.SetStateAction<any[]>>; deckLayer?: DeckLayer; + compareDeckLayer?: DeckLayer; + deckLayers?: DeckLayer[]; + compareMap?: OlMap; + maps?: OlMap[]; diameterRange?: [number, number]; elevationRange?: [number, number]; } @@ -128,12 +140,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const mapRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null); + const compareMapRef = useRef<HTMLDivElement | null>(null); + const compareCanvasRef = useRef<HTMLCanvasElement | null>(null); const deckLayerRef = useRef<DeckLayer | null>(null); + const compareDeckLayerRef = useRef<DeckLayer | null>(null); const isDisposingRef = useRef(false); + const isCompareDisposingRef = useRef(false); const pendingTimeoutsRef = useRef<number[]>([]); const [map, setMap] = useState<OlMap>(); const [deckLayer, setDeckLayer] = useState<DeckLayer>(); + const [compareMap, setCompareMap] = useState<OlMap>(); + const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>(); // currentCalData 用于存储当前计算结果 const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间 // const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17")); @@ -144,6 +162,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { [], ); const [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]); + const [compareJunctionCalData, setCompareJunctionCalData] = useState<any[]>( + [], + ); + const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]); + const [isCompareMode, setCompareMode] = useState(false); // junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 // currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 const [junctionData, setJunctionDataState] = useState<any[]>([]); @@ -201,6 +224,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }); }, [pipeData, currentPipeCalData, pipeText]); + const mergedCompareJunctionData = useMemo(() => { + const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r])); + return junctionData.map((j) => { + const record = nodeMap.get(j.id); + let val = record ? record.value : undefined; + if (val !== undefined && junctionText === "actualdemand") { + val = toM3h(val, "lps"); + } + return record ? { ...j, [junctionText]: val } : j; + }); + }, [junctionData, compareJunctionCalData, junctionText]); + + const mergedComparePipeData = useMemo(() => { + const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r])); + return pipeData.map((p) => { + const record = linkMap.get(p.id); + if (!record) return p; + const isFlow = pipeText === "flow"; + let val = record.value; + if (val !== undefined && isFlow) { + val = toM3h(val, "lps"); + } + return { + ...p, + [pipeText]: isFlow ? Math.abs(val) : val, + flowFlag: isFlow && record.value < 0 ? -1 : 1, + path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, + }; + }); + }, [pipeData, comparePipeCalData, pipeText]); + const [diameterRange, setDiameterRange] = useState< [number, number] | undefined >(); @@ -208,6 +262,24 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { [number, number] | undefined >(); + const toggleCompareMode = useCallback(() => { + setCompareMode((prev) => !prev); + }, []); + + const maps = useMemo( + () => + [map, isCompareMode ? compareMap : undefined].filter(Boolean) as OlMap[], + [compareMap, isCompareMode, map], + ); + + const deckLayers = useMemo( + () => + [deckLayer, isCompareMode ? compareDeckLayer : undefined].filter( + Boolean, + ) as DeckLayer[], + [compareDeckLayer, deckLayer, isCompareMode], + ); + const setJunctionData = (newData: any[]) => { const uniqueNewData = newData.filter((item) => { if (!item || !item.id) return false; @@ -518,6 +590,178 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }, }); + const createOperationalLayers = () => { + const nextJunctionSource = new VectorTileSource({ + url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, + format: new MVT(), + projection: "EPSG:3857", + }); + const nextPipeSource = new VectorTileSource({ + url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, + format: new MVT(), + projection: "EPSG:3857", + }); + const nextJunctionsLayer = new WebGLVectorTileLayer({ + source: nextJunctionSource as any, + style: defaultFlatStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "节点", + value: "junctions", + type: "point", + properties: [ + { name: "高程", value: "elevation" }, + { name: "实际需水量", value: "actual_demand" }, + { name: "水头", value: "total_head" }, + { name: "压力", value: "pressure" }, + { name: "水质", value: "quality" }, + ], + }, + }); + const nextPipesLayer = new WebGLVectorTileLayer({ + source: nextPipeSource as any, + style: defaultFlatStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "管道", + value: "pipes", + type: "linestring", + properties: [ + { name: "管径", value: "diameter" }, + { name: "流量", value: "flow" }, + { name: "摩阻系数", value: "friction" }, + { name: "水头损失", value: "headloss" }, + { name: "单位水头损失", value: "unit_headloss" }, + { name: "水质", value: "quality" }, + { name: "反应速率", value: "reaction" }, + { name: "设置值", value: "setting" }, + { name: "状态", value: "status" }, + { name: "流速", value: "velocity" }, + ], + }, + }); + const nextValvesLayer = new WebGLVectorTileLayer({ + source: valveSource as any, + style: valveStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 16, + properties: { + name: "阀门", + value: "valves", + type: "linestring", + properties: [], + }, + }); + const nextReservoirsLayer = new VectorLayer({ + source: reservoirSource, + style: reservoirStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水库", + value: "reservoirs", + type: "point", + properties: [], + }, + }); + const nextPumpsLayer = new VectorLayer({ + source: pumpSource, + style: pumpStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水泵", + value: "pumps", + type: "linestring", + properties: [], + }, + }); + const nextTanksLayer = new VectorLayer({ + source: tankSource, + style: tankStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水箱", + value: "tanks", + type: "point", + properties: [], + }, + }); + const nextScadaLayer = new VectorLayer({ + source: scadaSource, + style: scadaStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "SCADA", + value: "scada", + type: "point", + properties: [], + }, + }); + + const availableLayers: any[] = []; + config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => { + switch (layerValue) { + case "junctions": + availableLayers.push(nextJunctionsLayer); + break; + case "pipes": + availableLayers.push(nextPipesLayer); + break; + case "valves": + availableLayers.push(nextValvesLayer); + break; + case "reservoirs": + availableLayers.push(nextReservoirsLayer); + break; + case "pumps": + availableLayers.push(nextPumpsLayer); + break; + case "tanks": + availableLayers.push(nextTanksLayer); + break; + case "scada": + availableLayers.push(nextScadaLayer); + break; + } + }); + availableLayers.sort((a, b) => { + const order = [ + "valves", + "junctions", + "scada", + "reservoirs", + "pumps", + "tanks", + "pipes", + ].reverse(); + const getValue = (layer: any) => { + const props = layer.get ? layer.get("properties") : undefined; + return (props && props.value) || layer.get?.("value") || ""; + }; + const aVal = getValue(a); + const bVal = getValue(b); + let ia = order.indexOf(aVal); + let ib = order.indexOf(bVal); + if (ia === -1) ia = order.length; + if (ib === -1) ib = order.length; + return ia - ib; + }); + + return availableLayers; + }; + // The map and layer instances are intentionally rebuilt only when workspace or extent changes. useEffect(() => { if (!mapRef.current) return; @@ -857,148 +1101,284 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [MAP_WORKSPACE, MAP_EXTENT]); + useEffect(() => { + if (!isCompareMode) { + isCompareDisposingRef.current = true; + setCompareJunctionCalData([]); + setComparePipeCalData([]); + return; + } + if (!map || !compareMapRef.current || !compareCanvasRef.current) return; + + isCompareDisposingRef.current = false; + const availableLayers = createOperationalLayers(); + const nextCompareMap = new OlMap({ + target: compareMapRef.current, + view: map.getView(), + layers: availableLayers.slice(), + controls: [], + }); + nextCompareMap.getAllLayers().forEach((layer) => { + const layerId = layer.get("value"); + if (!layerId) return; + const primaryLayer = map + .getAllLayers() + .find((currentLayer) => currentLayer.get("value") === layerId); + if (primaryLayer) { + layer.setVisible(primaryLayer.getVisible()); + } + }); + setCompareMap(nextCompareMap); + + const compareDeck = new Deck({ + initialViewState: { + longitude: 0, + latitude: 0, + zoom: 1, + }, + canvas: compareCanvasRef.current, + controller: false, + layers: [], + }); + const nextCompareDeckLayer = new DeckLayer( + compareDeck, + compareCanvasRef.current, + { + name: "compareDeckLayer", + value: "deckLayer", + }, + ); + compareDeckLayerRef.current = nextCompareDeckLayer; + setCompareDeckLayer(nextCompareDeckLayer); + nextCompareMap.addLayer(nextCompareDeckLayer); + + const resizeTimerId = window.setTimeout(() => { + map.updateSize(); + nextCompareMap.updateSize(); + }, 0); + + return () => { + isCompareDisposingRef.current = true; + window.clearTimeout(resizeTimerId); + if ( + compareDeckLayerRef.current && + !compareDeckLayerRef.current.isDisposedLayer() + ) { + try { + nextCompareMap.removeLayer(compareDeckLayerRef.current); + } catch { + // Layer may have already been removed during teardown. + } + compareDeckLayerRef.current.disposeDeck(); + } + compareDeckLayerRef.current = null; + setCompareDeckLayer(undefined); + setCompareMap(undefined); + nextCompareMap.setTarget(undefined); + nextCompareMap.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isCompareMode, map]); + + useEffect(() => { + const resizeTimerId = window.setTimeout(() => { + map?.updateSize(); + compareMap?.updateSize(); + }, 0); + + return () => { + window.clearTimeout(resizeTimerId); + }; + }, [compareMap, isCompareMode, map]); + // 当数据变化时,更新 deck.gl 图层 useEffect(() => { - if (isDisposingRef.current) return; - const deckLayer = deckLayerRef.current; - if (!deckLayer) return; // 如果 deck 实例还未创建,则退出 - if (deckLayer.isDisposedLayer()) return; - if (!mergedJunctionData.length) return; - if (!mergedPipeData.length) return; - const junctionTextLayer = new TextLayer({ - id: "junctionTextLayer", - name: "节点文字", - zIndex: 10, - data: mergedJunctionData, - getPosition: (d: any) => d.position, - fontFamily: "Monaco, monospace", - getText: (d: any) => { - let idPart = showJunctionId ? d.id : ""; - let propPart = ""; - if (showJunctionTextLayer && d[junctionText] !== undefined) { - const value = (d[junctionText] as number).toFixed(3); - propPart = `${value}`; - } - if (idPart && propPart) return `${idPart} - ${propPart}`; - return idPart || propPart; - }, - getSize: 14, - fontWeight: "bold", - getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见 - getAngle: 0, - getTextAnchor: "middle", - getAlignmentBaseline: "center", - getPixelOffset: [0, -10], - visible: + const syncDeckOverlay = ( + targetDeckLayer: DeckLayer | null, + targetJunctionData: any[], + targetPipeData: any[], + disposing: boolean, + ) => { + if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { + return; + } + const shouldShowJunctionText = (showJunctionTextLayer || showJunctionId) && currentZoom >= 15 && - currentZoom <= 24, - updateTriggers: { - getText: [showJunctionId, showJunctionTextLayer, junctionText], - }, - extensions: [new CollisionFilterExtension()], - collisionTestProps: { - sizeScale: 3, - }, - characterSet: "auto", - fontSettings: { - sdf: true, - fontSize: 64, - buffer: 6, - }, - // outlineWidth: 3, - // outlineColor: [255, 255, 255, 220], - }); - - const pipeTextLayer = new TextLayer({ - id: "pipeTextLayer", - name: "管道文字", - zIndex: 10, - data: mergedPipeData, - getPosition: (d: any) => d.position, - fontFamily: "Monaco, monospace", - getText: (d: any) => { - let idPart = showPipeId ? d.id : ""; - let propPart = ""; - if (showPipeTextLayer && d[pipeText] !== undefined) { - let value; - if (pipeText === "unit_headloss") { - value = ( - (d["unit_headloss"] / (d["length"] / 1000)) as number - ).toFixed(3); - } else { - value = Math.abs(d[pipeText] as number).toFixed(3); - } - propPart = `${value}`; - } - if (idPart && propPart) return `${idPart} - ${propPart}`; - return idPart || propPart; - }, - getSize: 14, - fontWeight: "bold", - getColor: [33, 37, 41], // 深灰色 - getAngle: (d: any) => d.angle || 0, - getPixelOffset: [0, -8], - getTextAnchor: "middle", - getAlignmentBaseline: "bottom", - visible: + currentZoom <= 24 && + targetJunctionData.length > 0; + const shouldShowPipeText = (showPipeTextLayer || showPipeId) && currentZoom >= 15 && - currentZoom <= 24, - updateTriggers: { - getText: [showPipeId, showPipeTextLayer, pipeText], - }, - extensions: [new CollisionFilterExtension()], - collisionTestProps: { - sizeScale: 3, - }, - characterSet: "auto", - fontSettings: { - sdf: true, - fontSize: 64, - buffer: 6, - }, - // outlineWidth: 3, - // outlineColor: [255, 255, 255, 220], - }); + currentZoom <= 24 && + targetPipeData.length > 0; + const shouldShowContour = + showContourLayer && + currentZoom >= 11 && + currentZoom <= 24 && + targetJunctionData.length > 0; - const contourLayer = new ContourLayer({ - id: "junctionContourLayer", - name: "等值线", - data: mergedJunctionData, - aggregation: "MEAN", - cellSize: 600, - strokeWidth: 0, - contours: contours, - getPosition: (d) => d.position, - getWeight: (d: any) => - (d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number), - opacity: 1, - visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24, - updateTriggers: { - // 当 mergedJunctionData 内部数据更新时,通知 getWeight 重新计算 - getWeight: [mergedJunctionData, junctionText], - }, - }); - if (deckLayer.getDeckLayerById("junctionTextLayer")) { - // 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法 - deckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer); - } else { - deckLayer.addDeckLayer(junctionTextLayer); - } - if (deckLayer.getDeckLayerById("pipeTextLayer")) { - deckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer); - } else { - deckLayer.addDeckLayer(pipeTextLayer); - } - if (deckLayer.getDeckLayerById("junctionContourLayer")) { - deckLayer.updateDeckLayer("junctionContourLayer", contourLayer); - } else { - deckLayer.addDeckLayer(contourLayer); + if (!shouldShowJunctionText) { + targetDeckLayer.removeDeckLayer("junctionTextLayer"); + } + if (!shouldShowPipeText) { + targetDeckLayer.removeDeckLayer("pipeTextLayer"); + } + if (!shouldShowContour) { + targetDeckLayer.removeDeckLayer("junctionContourLayer"); + } + if (!shouldShowJunctionText && !shouldShowPipeText && !shouldShowContour) { + return; + } + + const junctionTextLayer = shouldShowJunctionText + ? new TextLayer({ + id: "junctionTextLayer", + name: "节点文字", + zIndex: 10, + data: targetJunctionData, + getPosition: (d: any) => d.position, + fontFamily: "Monaco, monospace", + getText: (d: any) => { + let idPart = showJunctionId ? d.id : ""; + let propPart = ""; + if (showJunctionTextLayer && d[junctionText] !== undefined) { + const value = (d[junctionText] as number).toFixed(3); + propPart = `${value}`; + } + if (idPart && propPart) return `${idPart} - ${propPart}`; + return idPart || propPart; + }, + getSize: 14, + fontWeight: "bold", + getColor: [33, 37, 41], + getAngle: 0, + getTextAnchor: "middle", + getAlignmentBaseline: "center", + getPixelOffset: [0, -10], + visible: true, + updateTriggers: { + getText: [showJunctionId, showJunctionTextLayer, junctionText], + }, + extensions: [new CollisionFilterExtension()], + collisionTestProps: { + sizeScale: 3, + }, + characterSet: "auto", + fontSettings: { + sdf: true, + fontSize: 64, + buffer: 6, + }, + }) + : null; + + const pipeTextLayer = shouldShowPipeText + ? new TextLayer({ + id: "pipeTextLayer", + name: "管道文字", + zIndex: 10, + data: targetPipeData, + getPosition: (d: any) => d.position, + fontFamily: "Monaco, monospace", + getText: (d: any) => { + let idPart = showPipeId ? d.id : ""; + let propPart = ""; + if (showPipeTextLayer && d[pipeText] !== undefined) { + let value; + if (pipeText === "unit_headloss") { + value = ( + (d["unit_headloss"] / (d["length"] / 1000)) as number + ).toFixed(3); + } else { + value = Math.abs(d[pipeText] as number).toFixed(3); + } + propPart = `${value}`; + } + if (idPart && propPart) return `${idPart} - ${propPart}`; + return idPart || propPart; + }, + getSize: 14, + fontWeight: "bold", + getColor: [33, 37, 41], + getAngle: (d: any) => d.angle || 0, + getPixelOffset: [0, -8], + getTextAnchor: "middle", + getAlignmentBaseline: "bottom", + visible: true, + updateTriggers: { + getText: [showPipeId, showPipeTextLayer, pipeText], + }, + extensions: [new CollisionFilterExtension()], + collisionTestProps: { + sizeScale: 3, + }, + characterSet: "auto", + fontSettings: { + sdf: true, + fontSize: 64, + buffer: 6, + }, + }) + : null; + + const contourLayer = shouldShowContour + ? new ContourLayer({ + id: "junctionContourLayer", + name: "等值线", + data: targetJunctionData, + aggregation: "MEAN", + cellSize: 600, + strokeWidth: 0, + contours: contours, + getPosition: (d) => d.position, + getWeight: (d: any) => + (d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number), + opacity: 1, + visible: true, + updateTriggers: { + getWeight: [targetJunctionData, junctionText], + }, + }) + : null; + + if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) { + targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer); + } else if (junctionTextLayer) { + targetDeckLayer.addDeckLayer(junctionTextLayer); + } + if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) { + targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer); + } else if (pipeTextLayer) { + targetDeckLayer.addDeckLayer(pipeTextLayer); + } + if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) { + targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer); + } else if (contourLayer) { + targetDeckLayer.addDeckLayer(contourLayer); + } + }; + + syncDeckOverlay( + deckLayerRef.current, + mergedJunctionData, + mergedPipeData, + isDisposingRef.current, + ); + if (isCompareMode) { + syncDeckOverlay( + compareDeckLayerRef.current, + mergedCompareJunctionData, + mergedComparePipeData, + isCompareDisposingRef.current, + ); } }, [ mergedJunctionData, mergedPipeData, + mergedCompareJunctionData, + mergedComparePipeData, + isCompareMode, junctionText, pipeText, currentZoom, @@ -1012,57 +1392,69 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 控制流动动画开关 useEffect(() => { - if (isDisposingRef.current) return; - if (pipeText === "flow" && currentPipeCalData.length > 0) { - flowAnimation.current = true; - } else { - flowAnimation.current = false; - } - const deckLayer = deckLayerRef.current; - if (!deckLayer) return; // 如果 deck 实例还未创建,则退出 + flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0; + const shouldShowWaterflow = + isWaterflowLayerAvailable && + showWaterflowLayer && + flowAnimation.current && + currentZoom >= 12 && + currentZoom <= 24; - let animationFrameId: number; // 保存 requestAnimationFrame 的 ID + let animationFrameId: number; - // 动画循环 - const animate = () => { - if (isDisposingRef.current || deckLayer.isDisposedLayer()) return; - // 动画总时长(秒) + const syncWaterflowLayer = ( + targetDeckLayer: DeckLayer | null, + targetPipeData: any[], + disposing: boolean, + ) => { + if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { + return; + } + if (!shouldShowWaterflow || targetPipeData.length === 0) { + targetDeckLayer.removeDeckLayer("waterflowLayer"); + return; + } const animationDuration = 10; const bufferTime = 2; const loopLength = animationDuration + bufferTime; - const currentTime = (Date.now() / 1000) % loopLength; + const currentFrameTime = (Date.now() / 1000) % loopLength; const waterflowLayer = new TripsLayer({ id: "waterflowLayer", name: "水流", - data: mergedPipeData, + data: targetPipeData, getPath: (d) => d.path, - getTimestamps: (d) => { - return d.timestamps; // 这些应该是与 currentTime 匹配的数值 - }, + getTimestamps: (d) => d.timestamps, getColor: [0, 220, 255], opacity: 0.8, - visible: - isWaterflowLayerAvailable && - showWaterflowLayer && - flowAnimation.current && // 保持动画标志作为可见性的一部分 - currentZoom >= 12 && - currentZoom <= 24, + visible: true, widthMinPixels: 5, - jointRounded: true, // 拐角变圆 - // capRounded: true, // 端点变圆 - trailLength: 2, // 水流尾迹淡出时间 - currentTime: currentTime, + jointRounded: true, + trailLength: 2, + currentTime: currentFrameTime, }); - if (deckLayer.getDeckLayerById("waterflowLayer")) { - deckLayer.updateDeckLayer("waterflowLayer", waterflowLayer); + if (targetDeckLayer.getDeckLayerById("waterflowLayer")) { + targetDeckLayer.updateDeckLayer("waterflowLayer", waterflowLayer); } else { - deckLayer.addDeckLayer(waterflowLayer); + targetDeckLayer.addDeckLayer(waterflowLayer); } + }; - // 只有在需要动画时才请求下一帧,但图层已经添加到了 deckLayer 中 - if (flowAnimation.current) { + const animate = () => { + syncWaterflowLayer( + deckLayerRef.current, + mergedPipeData, + isDisposingRef.current, + ); + if (isCompareMode) { + syncWaterflowLayer( + compareDeckLayerRef.current, + mergedComparePipeData, + isCompareDisposingRef.current, + ); + } + if (shouldShowWaterflow) { animationFrameId = requestAnimationFrame(animate); } }; @@ -1078,6 +1470,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { currentPipeCalData, currentZoom, mergedPipeData, + mergedComparePipeData, + isCompareMode, pipeText, isWaterflowLayerAvailable, showWaterflowLayer, @@ -1097,6 +1491,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { setCurrentJunctionCalData, currentPipeCalData, setCurrentPipeCalData, + compareJunctionCalData, + setCompareJunctionCalData, + comparePipeCalData, + setComparePipeCalData, + isCompareMode, + setCompareMode, + toggleCompareMode, setShowJunctionTextLayer, setShowPipeTextLayer, setShowJunctionId, @@ -1115,17 +1516,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { pipeText, setContours, deckLayer, + compareDeckLayer, + deckLayers, + compareMap, + maps, diameterRange, elevationRange, }} > <MapContext.Provider value={map}> <div className="relative w-full h-full"> - <div ref={mapRef} className="w-full h-full"></div> + <div className="flex w-full h-full"> + <div + className={`relative h-full ${isCompareMode ? "w-1/2" : "w-full"}`} + > + <div ref={mapRef} className="w-full h-full"></div> + <canvas + ref={canvasRef} + className="pointer-events-none absolute inset-0" + /> + {isCompareMode && ( + <div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white"> + 方案模拟 + </div> + )} + </div> + {isCompareMode && ( + <div className="relative h-full w-1/2 border-l border-white/40"> + <div ref={compareMapRef} className="w-full h-full"></div> + <canvas + ref={compareCanvasRef} + className="pointer-events-none absolute inset-0" + /> + <div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white"> + 实时模拟 + </div> + </div> + )} + </div> + {isCompareMode && ( + <div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px -translate-x-1/2 bg-white/85 shadow-[0_0_0_1px_rgba(15,23,42,0.18)]" /> + )} <MapTools /> {children} </div> - <canvas ref={canvasRef} /> </MapContext.Provider> </DataContext.Provider> </> -- 2.54.0 From 3db2af0271004c0ddc846dfa0c09cc15b9d65e20 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 27 Apr 2026 16:00:02 +0800 Subject: [PATCH 111/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.mjs | 1 + tsconfig.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index f07358f..44ee2f9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + distDir: process.env.NEXT_DIST_DIR || ".next", output: "standalone", images: { remotePatterns: [ diff --git a/tsconfig.json b/tsconfig.json index 868d7b5..d90975c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,8 +63,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + ".next*/types/**/*.ts", + ".next*/dev/types/**/*.ts" ], "exclude": [ "node_modules" -- 2.54.0 From 49fd4f5eb16b29e749a502b09713f75217e25c2f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 27 Apr 2026 16:08:00 +0800 Subject: [PATCH 112/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=AF=94=E8=BE=83?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=8F=90=E7=A4=BA=E6=A1=86=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/core/MapComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 546ac49..94d76e2 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -1536,7 +1536,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { className="pointer-events-none absolute inset-0" /> {isCompareMode && ( - <div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white"> + <div className="pointer-events-none absolute right-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white"> 方案模拟 </div> )} -- 2.54.0 From 3b5a493cdae58abf13c92dd080b314670fa15c5f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 29 Apr 2026 15:33:08 +0800 Subject: [PATCH 113/281] =?UTF-8?q?=E9=80=82=E9=85=8D=E6=96=B0=E7=9A=84=20?= =?UTF-8?q?opencode=20Agent=20=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- .gitea/workflows/package.yml | 2 +- Dockerfile | 2 +- docker-compose.yml | 2 +- src/components/chat/GlobalChatbox.tsx | 26 ++++++++++---------- src/components/chat/GlobalChatbox.types.ts | 2 +- src/components/chat/GlobalChatbox.utils.ts | 12 +++++----- src/config/config.ts | 2 +- src/lib/chatStream.test.ts | 28 +++++++++++----------- src/lib/chatStream.ts | 28 +++++++++++----------- 10 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.env b/.env index 04d8be8..14d3546 100644 --- a/.env +++ b/.env @@ -6,7 +6,7 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/" # 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn" -NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn" +NEXT_PUBLIC_AGENT_URL="https://agent.waternetwork.cn" NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn" NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" NEXT_PUBLIC_MAP_WORKSPACE="tjwater" diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 639e848..26fd48d 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -102,7 +102,7 @@ jobs: -t "${IMAGE_NAME}:${IMAGE_TAG}" \ -t "${IMAGE_NAME}:latest" \ --build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \ - --build-arg NEXT_PUBLIC_COPILOT_URL="${{ vars.NEXT_PUBLIC_COPILOT_URL }}" \ + --build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \ --build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \ --build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \ --build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \ diff --git a/Dockerfile b/Dockerfile index 282bfa8..7da72fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ FROM base AS builder # 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数 # Next.js 在 build 时会自动读取同名的 ARG 作为环境变量 ARG NEXT_PUBLIC_BACKEND_URL -ARG NEXT_PUBLIC_COPILOT_URL +ARG NEXT_PUBLIC_AGENT_URL ARG NEXT_PUBLIC_AUDIO_SERVICE_URL ARG NEXT_PUBLIC_MAP_URL ARG NEXT_PUBLIC_MAP_WORKSPACE diff --git a/docker-compose.yml b/docker-compose.yml index 52a00e8..b8d860b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: dockerfile: Dockerfile args: NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL} - NEXT_PUBLIC_COPILOT_URL: ${NEXT_PUBLIC_COPILOT_URL} + NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL} NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL} NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL} NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE} diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 90854e8..d01aa9b 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -32,7 +32,7 @@ import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRound import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; // Logic -import { streamCopilotChat } from "@/lib/chatStream"; +import { streamAgentChat } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; import { useChatToolStore, @@ -60,8 +60,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [isStreaming, setIsStreaming] = useState(false); const [width, setWidth] = useState(480); const [isResizing, setIsResizing] = useState(false); - const [conversationId, setConversationId] = useState<string | undefined>( - initialChatStateRef.current.conversationId + const [sessionId, setSessionId] = useState<string | undefined>( + initialChatStateRef.current.sessionId ); const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null); const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false); @@ -117,13 +117,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }, [open]); useEffect(() => { - const state: PersistedChatState = { messages, conversationId }; + const state: PersistedChatState = { messages, sessionId }; try { window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); } catch (error) { console.error("[GlobalChatbox] Failed to persist chat state:", error); } - }, [messages, conversationId]); + }, [messages, sessionId]); const sendPrompt = useCallback( async (rawPrompt: string) => { @@ -291,13 +291,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }; try { - await streamCopilotChat({ + await streamAgentChat({ message: prompt, - conversationId, + sessionId, signal: controller.signal, onEvent: (event) => { if (event.type === "token") { - if (!conversationId && event.conversationId) setConversationId(event.conversationId); + if (!sessionId && event.sessionId) setSessionId(event.sessionId); const normalizedToken = normalizeThoughtTagToken(event.content); setMessages((prev) => prev.map((m) => @@ -307,13 +307,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { ) ); } else if (event.type === "done") { - if (!conversationId && event.conversationId) setConversationId(event.conversationId); + if (!sessionId && event.sessionId) setSessionId(event.sessionId); setMessages((prev) => prev.map((m) => m.id === assistantId && m.content.trim().length === 0 ? { ...m, - content: "⚠️ **错误:** Copilot 未返回内容,请稍后重试。", + content: "⚠️ **错误:** Agent 未返回内容,请稍后重试。", isError: true, } : m @@ -358,7 +358,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { setIsStreaming(false); } }, - [conversationId, isStreaming, stopListening, dispatchToolAction], + [sessionId, isStreaming, stopListening, dispatchToolAction], ); const handleSend = async () => { @@ -573,7 +573,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <Box> <Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}> - Copilot + Agent </Typography> <Typography variant="caption" color="text.secondary" fontWeight={500}> 你的 AI 助手 @@ -834,7 +834,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { void handleSend(); } }} - placeholder="输入消息给 Copilot..." + placeholder="输入消息给 Agent..." fullWidth multiline maxRows={3} diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index d7546ce..b7155da 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -14,5 +14,5 @@ export type SpeechState = "idle" | "playing" | "paused"; export type PersistedChatState = { messages: Message[]; - conversationId?: string; + sessionId?: string; }; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index ff0b069..94846a2 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -2,7 +2,7 @@ import type { PersistedChatState } from "./GlobalChatbox.types"; export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -export const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1"; +export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1"; const THINK_TAG_ALIAS_PATTERN = /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; export const PRESET_PROMPTS = [ @@ -36,24 +36,24 @@ export const stripMarkdown = (md: string): string => export const getInitialChatState = (): PersistedChatState => { if (typeof window === "undefined") { - return { messages: [], conversationId: undefined }; + return { messages: [], sessionId: undefined }; } try { const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); - if (!storedRaw) return { messages: [], conversationId: undefined }; + if (!storedRaw) return { messages: [], sessionId: undefined }; const parsed = JSON.parse(storedRaw) as PersistedChatState; if (!Array.isArray(parsed.messages)) { console.error("[GlobalChatbox] Invalid persisted messages format."); window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], conversationId: undefined }; + return { messages: [], sessionId: undefined }; } - return { messages: parsed.messages, conversationId: parsed.conversationId }; + return { messages: parsed.messages, sessionId: parsed.sessionId }; } catch (error) { console.error( "[GlobalChatbox] Failed to read persisted chat state:", error, ); window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], conversationId: undefined }; + return { messages: [], sessionId: undefined }; } }; diff --git a/src/config/config.ts b/src/config/config.ts index a5bf841..b2126e3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,6 +1,6 @@ export const config = { BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", - COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787", + AGENT_URL: process.env.NEXT_PUBLIC_AGENT_URL || "http://127.0.0.1:8788", AUDIO_SERVICE_URL: process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083", MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index c138d8a..4528f51 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -1,4 +1,4 @@ -import { streamCopilotChat } from "./chatStream"; +import { streamAgentChat } from "./chatStream"; import { ReadableStream } from "stream/web"; import { TextEncoder, TextDecoder } from "util"; @@ -32,7 +32,7 @@ const makeStream = (chunks: string[]) => }, }); -describe("streamCopilotChat", () => { +describe("streamAgentChat", () => { beforeEach(() => { apiFetch.mockReset(); }); @@ -41,21 +41,21 @@ describe("streamCopilotChat", () => { apiFetch.mockResolvedValue({ ok: true, body: makeStream([ - 'event: token\ndata: {"conversationId":"c1","content":"he"}\n\n', - 'event: token\ndata: {"conversationId":"c1","content":"llo"}\n\n', - 'event: done\ndata: {"conversationId":"c1"}\n\n', + 'event: token\ndata: {"session_id":"s1","content":"he"}\n\n', + 'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n', + 'event: done\ndata: {"session_id":"s1"}\n\n', ]), }); - const events: Array<{ type: string; content?: string; conversationId?: string }> = []; + const events: Array<{ type: string; content?: string; sessionId?: string }> = []; - await streamCopilotChat({ + await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event), }); expect(apiFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/copilot/chat/stream"), + expect.stringContaining("/api/v1/agent/chat/stream"), expect.objectContaining({ method: "POST", projectHeaderMode: "include", @@ -64,9 +64,9 @@ describe("streamCopilotChat", () => { ); expect(events).toEqual([ - { type: "token", conversationId: "c1", content: "he" }, - { type: "token", conversationId: "c1", content: "llo" }, - { type: "done", conversationId: "c1" }, + { type: "token", sessionId: "s1", content: "he" }, + { type: "token", sessionId: "s1", content: "llo" }, + { type: "done", sessionId: "s1" }, ]); }); @@ -78,7 +78,7 @@ describe("streamCopilotChat", () => { }); const events: Array<{ type: string; message?: string; detail?: string }> = []; - await streamCopilotChat({ + await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event), }); @@ -97,7 +97,7 @@ describe("streamCopilotChat", () => { }); const events: Array<{ type: string; message?: string; detail?: string }> = []; - await streamCopilotChat({ + await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event), }); @@ -111,7 +111,7 @@ describe("streamCopilotChat", () => { apiFetch.mockRejectedValue(new TypeError("Failed to fetch")); const events: Array<{ type: string; message?: string; detail?: string }> = []; - await streamCopilotChat({ + await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event), }); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 3745809..772658c 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -2,24 +2,24 @@ import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; export type StreamEvent = - | { type: "token"; conversationId: string; content: string } - | { type: "done"; conversationId: string } + | { type: "token"; sessionId: string; content: string } + | { type: "done"; sessionId: string } | { type: "error"; - conversationId?: string; + sessionId?: string; message: string; detail?: string; } | { type: "tool_call"; - conversationId: string; + sessionId: string; tool: string; params: Record<string, unknown>; }; type StreamOptions = { message: string; - conversationId?: string; + sessionId?: string; signal?: AbortSignal; onEvent: (event: StreamEvent) => void; }; @@ -43,16 +43,16 @@ const parseEventBlock = (block: string): { event?: string; data?: string } => { }; }; -export const streamCopilotChat = async ({ +export const streamAgentChat = async ({ message, - conversationId, + sessionId, signal, onEvent, }: StreamOptions) => { let response: Response; try { response = await apiFetch( - `${config.COPILOT_URL}/api/v1/copilot/chat/stream`, + `${config.AGENT_URL}/api/v1/agent/chat/stream`, { method: "POST", signal, @@ -62,7 +62,7 @@ export const streamCopilotChat = async ({ }, body: JSON.stringify({ message, - conversation_id: conversationId, + session_id: sessionId, }), projectHeaderMode: "include", skipAuthRedirect: true, @@ -115,7 +115,7 @@ export const streamCopilotChat = async ({ try { const parsed = JSON.parse(data) as { - conversationId?: string; + session_id?: string; content?: string; message?: string; detail?: string; @@ -125,25 +125,25 @@ export const streamCopilotChat = async ({ if (event === "token") { onEvent({ type: "token", - conversationId: parsed.conversationId ?? "", + sessionId: parsed.session_id ?? "", content: parsed.content ?? "", }); } else if (event === "done") { onEvent({ type: "done", - conversationId: parsed.conversationId ?? "", + sessionId: parsed.session_id ?? "", }); } else if (event === "error") { onEvent({ type: "error", - conversationId: parsed.conversationId, + sessionId: parsed.session_id, message: parsed.message ?? "unknown error", detail: parsed.detail, }); } else if (event === "tool_call") { onEvent({ type: "tool_call", - conversationId: parsed.conversationId ?? "", + sessionId: parsed.session_id ?? "", tool: parsed.tool ?? "", params: parsed.params ?? {}, }); -- 2.54.0 From 30d85173ee5ea2075f174faa23ce6ab46312b8ac Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 29 Apr 2026 15:42:37 +0800 Subject: [PATCH 114/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D=20?= =?UTF-8?q?ID=20=E8=AE=BE=E7=BD=AE=E9=94=99=E8=AF=AF=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 2 +- tsconfig.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index d01aa9b..2835684 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -396,7 +396,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { handleStopSpeech(); stopListening(); setMessages([]); - setConversationId(undefined); + setSessionId(undefined); setInput(""); setIsStreaming(false); handleHeaderMenuClose(); diff --git a/tsconfig.json b/tsconfig.json index d90975c..f25bd34 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,10 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "types": [ + "jest", + "node" + ], "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", -- 2.54.0 From 2c1afdc97c459fffc4690e80b4f7547c4220d16f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 29 Apr 2026 16:55:14 +0800 Subject: [PATCH 115/281] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=EF=BC=8C=E4=BC=98=E5=8C=96=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.parts.tsx | 81 ++++++++++++++++++++- src/components/chat/GlobalChatbox.tsx | 33 ++++++++- src/components/chat/GlobalChatbox.types.ts | 9 +++ src/lib/chatStream.test.ts | 27 +++++++ src/lib/chatStream.ts | 23 ++++++ tsconfig.json | 4 +- 6 files changed, 174 insertions(+), 3 deletions(-) diff --git a/src/components/chat/GlobalChatbox.parts.tsx b/src/components/chat/GlobalChatbox.parts.tsx index 3cd086b..5b03fb8 100644 --- a/src/components/chat/GlobalChatbox.parts.tsx +++ b/src/components/chat/GlobalChatbox.parts.tsx @@ -7,7 +7,9 @@ import { motion } from "framer-motion"; import { Avatar, Box, + Chip, IconButton, + LinearProgress, Paper, Stack, Typography, @@ -15,7 +17,9 @@ import { } from "@mui/material"; import type { Theme } from "@mui/material/styles"; import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import HourglassEmptyRounded from "@mui/icons-material/HourglassEmptyRounded"; import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; @@ -28,7 +32,7 @@ import { import { ChatInlineChart } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; -import type { Message, SpeechState } from "./GlobalChatbox.types"; +import type { ChatProgress, Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; export const TypingIndicator = () => { @@ -267,6 +271,9 @@ export const ChatMessageItem = React.memo( : "#475569", }} > + {!isUser && !isErrorMessage && message.progress?.length ? ( + <ChatProgressPanel progress={message.progress} /> + ) : null} {contentSegments.map((segment, segIdx) => { if (segment.type === "text") { const text = segment.content.trim(); @@ -424,3 +431,75 @@ export const ChatMessageItem = React.memo( ); ChatMessageItem.displayName = "ChatMessageItem"; + +const ChatProgressPanel = ({ progress }: { progress: ChatProgress[] }) => { + const isComplete = progress.some( + (item) => item.phase === "complete" && item.status === "completed", + ); + const latestRunning = isComplete + ? undefined + : [...progress].reverse().find((item) => item.status === "running"); + return ( + <Box + sx={{ + mb: 1.5, + p: 1.25, + borderRadius: 2.5, + bgcolor: "rgba(99, 102, 241, 0.06)", + border: "1px solid rgba(99, 102, 241, 0.14)", + }} + > + <Stack spacing={1}> + <Stack direction="row" spacing={1} alignItems="center"> + <AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} /> + <Typography variant="caption" fontWeight={800} color="text.primary"> + Agent 过程 + </Typography> + {latestRunning ? ( + <Chip + size="small" + label={latestRunning.title} + sx={{ height: 20, fontSize: "0.68rem", bgcolor: "rgba(124, 58, 237, 0.08)" }} + /> + ) : null} + </Stack> + {latestRunning ? <LinearProgress sx={{ height: 4, borderRadius: 99 }} /> : null} + <Stack spacing={0.7}> + {progress.slice(-5).map((item) => ( + <Stack key={item.id} direction="row" spacing={0.8} alignItems="flex-start"> + {item.status === "completed" ? ( + <CheckCircleRounded sx={{ fontSize: 15, color: "success.main", mt: 0.2 }} /> + ) : item.status === "error" ? ( + <ErrorOutlineRounded sx={{ fontSize: 15, color: "error.main", mt: 0.2 }} /> + ) : ( + <HourglassEmptyRounded sx={{ fontSize: 15, color: "primary.main", mt: 0.2 }} /> + )} + <Box sx={{ minWidth: 0 }}> + <Typography variant="caption" color="text.primary" fontWeight={700}> + {item.title} + </Typography> + {item.detail ? ( + <Typography + variant="caption" + component="pre" + color="text.secondary" + sx={{ + display: "block", + mt: 0.25, + m: 0, + whiteSpace: "pre-wrap", + fontFamily: "inherit", + fontSize: "0.7rem", + }} + > + {item.detail} + </Typography> + ) : null} + </Box> + </Stack> + ))} + </Stack> + </Stack> + </Box> + ); +}; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 2835684..4868595 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -316,10 +316,41 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { content: "⚠️ **错误:** Agent 未返回内容,请稍后重试。", isError: true, } - : m + : m.id === assistantId + ? { + ...m, + progress: m.progress?.map((item) => + item.status === "running" + ? { ...item, status: "completed" as const } + : item, + ), + } + : m ) ); setIsStreaming(false); + } else if (event.type === "progress") { + if (!sessionId && event.sessionId) setSessionId(event.sessionId); + setMessages((prev) => + prev.map((m) => { + if (m.id !== assistantId) return m; + const progress = [...(m.progress ?? [])]; + const index = progress.findIndex((item) => item.id === event.id); + const nextProgress = { + id: event.id, + phase: event.phase, + status: event.status, + title: event.title, + detail: event.detail, + }; + if (index >= 0) { + progress[index] = nextProgress; + } else { + progress.push(nextProgress); + } + return { ...m, progress }; + }) + ); } else if (event.type === "error") { setMessages((prev) => prev.map((m) => diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index b7155da..e8d0a85 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -1,8 +1,17 @@ +export type ChatProgress = { + id: string; + phase: string; + status: "running" | "completed" | "error"; + title: string; + detail?: string; +}; + export type Message = { id: string; role: "user" | "assistant"; content: string; isError?: boolean; + progress?: ChatProgress[]; }; export type Props = { diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 4528f51..1db2bd5 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -70,6 +70,33 @@ describe("streamAgentChat", () => { ]); }); + it("parses progress events", async () => { + apiFetch.mockResolvedValue({ + ok: true, + body: makeStream([ + 'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n', + 'event: done\ndata: {"session_id":"s1"}\n\n', + ]), + }); + + const events: Array<{ type: string; title?: string; status?: string; detail?: string }> = []; + + await streamAgentChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events[0]).toEqual({ + type: "progress", + sessionId: "s1", + id: "p1", + phase: "tool", + status: "running", + title: "正在调用后端数据查询", + detail: "GET /api/v1/demo", + }); + }); + it("emits error when response is not ok", async () => { apiFetch.mockResolvedValue({ ok: false, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 772658c..ad163ad 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -4,6 +4,15 @@ import { config } from "@config/config"; export type StreamEvent = | { type: "token"; sessionId: string; content: string } | { type: "done"; sessionId: string } + | { + type: "progress"; + sessionId: string; + id: string; + phase: string; + status: "running" | "completed" | "error"; + title: string; + detail?: string; + } | { type: "error"; sessionId?: string; @@ -121,6 +130,10 @@ export const streamAgentChat = async ({ detail?: string; tool?: string; params?: Record<string, unknown>; + id?: string; + phase?: string; + status?: "running" | "completed" | "error"; + title?: string; }; if (event === "token") { onEvent({ @@ -128,6 +141,16 @@ export const streamAgentChat = async ({ sessionId: parsed.session_id ?? "", content: parsed.content ?? "", }); + } else if (event === "progress") { + onEvent({ + type: "progress", + sessionId: parsed.session_id ?? "", + id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`, + phase: parsed.phase ?? "progress", + status: parsed.status ?? "running", + title: parsed.title ?? "正在处理", + detail: parsed.detail, + }); } else if (event === "done") { onEvent({ type: "done", diff --git a/tsconfig.json b/tsconfig.json index f25bd34..a16d12d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -68,7 +68,9 @@ "**/*.ts", "**/*.tsx", ".next*/types/**/*.ts", - ".next*/dev/types/**/*.ts" + ".next*/dev/types/**/*.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" -- 2.54.0 From e5ca9e24aa7762fce357488354684806083bcc47 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 29 Apr 2026 17:15:49 +0800 Subject: [PATCH 116/281] =?UTF-8?q?Agent=20=E5=88=9D=E7=89=88=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentArtifactPanel.tsx | 128 +++ src/components/chat/AgentComposer.tsx | 241 +++++ src/components/chat/AgentHeader.tsx | 158 ++++ .../chat/AgentProgressTimeline.test.tsx | 60 ++ src/components/chat/AgentProgressTimeline.tsx | 188 ++++ src/components/chat/AgentTurn.tsx | 299 ++++++ src/components/chat/AgentWorkspace.tsx | 177 ++++ src/components/chat/GlobalChatbox.parts.tsx | 431 +-------- src/components/chat/GlobalChatbox.tsx | 895 ++---------------- src/components/chat/GlobalChatbox.types.ts | 12 + src/components/chat/GlobalChatbox.utils.ts | 9 +- .../chat/hooks/useAgentChatSession.ts | 239 +++++ .../chat/hooks/useAgentToolActions.ts | 237 +++++ 13 files changed, 1819 insertions(+), 1255 deletions(-) create mode 100644 src/components/chat/AgentArtifactPanel.tsx create mode 100644 src/components/chat/AgentComposer.tsx create mode 100644 src/components/chat/AgentHeader.tsx create mode 100644 src/components/chat/AgentProgressTimeline.test.tsx create mode 100644 src/components/chat/AgentProgressTimeline.tsx create mode 100644 src/components/chat/AgentTurn.tsx create mode 100644 src/components/chat/AgentWorkspace.tsx create mode 100644 src/components/chat/hooks/useAgentChatSession.ts create mode 100644 src/components/chat/hooks/useAgentToolActions.ts diff --git a/src/components/chat/AgentArtifactPanel.tsx b/src/components/chat/AgentArtifactPanel.tsx new file mode 100644 index 0000000..ab20934 --- /dev/null +++ b/src/components/chat/AgentArtifactPanel.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React from "react"; +import { + Box, + Chip, + Paper, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +import BarChartRounded from "@mui/icons-material/BarChartRounded"; +import LocationOnRounded from "@mui/icons-material/LocationOnRounded"; +import SensorsRounded from "@mui/icons-material/SensorsRounded"; +import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded"; + +import { ChatInlineChart } from "./ChatInlineChart"; +import type { ChatChartSeries } from "./ChatInlineChart"; +import type { AgentArtifact } from "./GlobalChatbox.types"; + +const artifactIcon = (kind: AgentArtifact["kind"]) => { + if (kind === "chart") return <BarChartRounded sx={{ fontSize: 18 }} />; + if (kind === "map") return <LocationOnRounded sx={{ fontSize: 18 }} />; + if (kind === "panel") return <SensorsRounded sx={{ fontSize: 18 }} />; + return <BuildCircleRounded sx={{ fontSize: 18 }} />; +}; + +const artifactColor = (kind: AgentArtifact["kind"], theme: Theme) => { + if (kind === "chart") return theme.palette.info.main; + if (kind === "map") return theme.palette.success.main; + if (kind === "panel") return theme.palette.warning.main; + return theme.palette.primary.main; +}; + +export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }) => { + const theme = useTheme(); + if (!artifacts.length) return null; + + return ( + <Stack spacing={1.25}> + <Stack direction="row" spacing={1} alignItems="center"> + <Typography variant="caption" fontWeight={800} color="text.primary"> + 结果与动作 + </Typography> + <Chip + size="small" + label={`${artifacts.length} 项`} + sx={{ height: 20, fontSize: "0.68rem" }} + /> + </Stack> + + {artifacts.map((artifact) => { + const color = artifactColor(artifact.kind, theme); + if (artifact.kind === "chart") { + return ( + <ChatInlineChart + key={artifact.id} + title={(artifact.params.title as string) ?? artifact.title} + chart_type={ + (artifact.params.chart_type as "line" | "bar" | "pie") ?? "line" + } + x_data={(artifact.params.x_data as string[]) ?? []} + series={(artifact.params.series as ChatChartSeries[]) ?? []} + x_axis_name={(artifact.params.x_axis_name as string) ?? undefined} + y_axis_name={(artifact.params.y_axis_name as string) ?? undefined} + /> + ); + } + + return ( + <Paper + key={artifact.id} + elevation={0} + sx={{ + p: 1.35, + borderRadius: 3, + border: `1px solid ${alpha(color, 0.22)}`, + bgcolor: alpha(color, 0.055), + }} + > + <Stack direction="row" spacing={1.25} alignItems="center"> + <Box + sx={{ + width: 32, + height: 32, + borderRadius: 2, + bgcolor: alpha(color, 0.12), + color, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {artifactIcon(artifact.kind)} + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="caption" fontWeight={800} color="text.primary"> + {artifact.title} + </Typography> + {artifact.description ? ( + <Typography + variant="caption" + color="text.secondary" + sx={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + > + {artifact.description} + </Typography> + ) : null} + </Box> + <Chip + size="small" + label="已执行" + sx={{ + height: 22, + fontSize: "0.68rem", + bgcolor: alpha(color, 0.12), + color, + }} + /> + </Stack> + </Paper> + ); + })} + </Stack> + ); +}; diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx new file mode 100644 index 0000000..af4e7c2 --- /dev/null +++ b/src/components/chat/AgentComposer.tsx @@ -0,0 +1,241 @@ +"use client"; + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + Avatar, + Box, + Chip, + Collapse, + IconButton, + Paper, + Stack, + TextField, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import SendRounded from "@mui/icons-material/SendRounded"; +import StopRounded from "@mui/icons-material/StopRounded"; +import MicRounded from "@mui/icons-material/MicRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; + +type AgentComposerProps = { + input: string; + inputRef: React.RefObject<HTMLInputElement | null>; + isStreaming: boolean; + isListening: boolean; + isSttSupported: boolean; + presets: string[]; + onInputChange: (value: string) => void; + onSend: () => void; + onAbort: () => void; + onStartListening: () => void; + onStopListening: () => void; + onPresetSelect: (prompt: string) => void; +}; + +export const AgentComposer = ({ + input, + inputRef, + isStreaming, + isListening, + isSttSupported, + presets, + onInputChange, + onSend, + onAbort, + onStartListening, + onStopListening, + onPresetSelect, +}: AgentComposerProps) => { + const theme = useTheme(); + const canSend = input.trim().length > 0 && !isStreaming; + const [isPresetOpen, setIsPresetOpen] = React.useState(false); + + return ( + <Box sx={{ px: 3, pb: 3, pt: 1.5, zIndex: 10 }}> + <Paper + elevation={0} + sx={{ + mb: isPresetOpen ? 1.25 : 0.8, + px: 1.2, + py: 0.85, + borderRadius: 3.5, + bgcolor: alpha("#fff", 0.72), + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + backdropFilter: "blur(12px)", + }} + > + <Stack direction="row" spacing={1} alignItems="center"> + <AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} /> + <Typography variant="caption" color="text.secondary" fontWeight={800}> + 常用管网任务 + </Typography> + <Box sx={{ flex: 1 }} /> + <IconButton + size="small" + onClick={() => setIsPresetOpen((value) => !value)} + aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"} + sx={{ width: 26, height: 26, color: "text.secondary" }} + > + {isPresetOpen ? ( + <KeyboardArrowDownRounded fontSize="small" /> + ) : ( + <KeyboardArrowUpRounded fontSize="small" /> + )} + </IconButton> + </Stack> + <Collapse in={isPresetOpen} timeout="auto" unmountOnExit> + <Stack direction="row" spacing={0.8} useFlexGap flexWrap="wrap" sx={{ pt: 0.9 }}> + {presets.map((prompt) => ( + <Chip + key={prompt} + label={prompt.replace(/[。.]$/, "")} + size="small" + clickable + onClick={() => { + onPresetSelect(prompt); + setIsPresetOpen(false); + }} + sx={{ + maxWidth: "100%", + height: 28, + borderRadius: 2, + bgcolor: alpha(theme.palette.primary.main, 0.07), + color: "text.primary", + fontWeight: 600, + "& .MuiChip-label": { + overflow: "hidden", + textOverflow: "ellipsis", + }, + }} + /> + ))} + </Stack> + </Collapse> + </Paper> + + <motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}> + <Stack + direction="row" + alignItems="center" + component={Paper} + elevation={12} + sx={{ + p: "6px 8px", + borderRadius: 5, + bgcolor: alpha("#fff", 0.92), + backdropFilter: "blur(10px)", + border: `1px solid ${alpha("#fff", 0.62)}`, + boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`, + }} + > + <Avatar + sx={{ + width: 28, + height: 28, + ml: 0.5, + background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`, + }} + > + <AutoAwesome sx={{ fontSize: 16, color: "#fff" }} /> + </Avatar> + <TextField + inputRef={inputRef} + value={input} + onChange={(event) => onInputChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + onSend(); + } + }} + placeholder="描述你的管网分析目标..." + fullWidth + multiline + maxRows={4} + variant="standard" + InputProps={{ + disableUnderline: true, + sx: { px: 2, py: 1.35, fontSize: "0.98rem" }, + }} + /> + + {isSttSupported ? ( + <Box sx={{ display: "flex", alignItems: "center", mr: 0.5 }}> + {isListening ? ( + <motion.div + animate={{ scale: [1, 1.14, 1] }} + transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} + > + <IconButton + onClick={onStopListening} + aria-label="停止语音输入" + sx={{ + color: "error.main", + bgcolor: alpha(theme.palette.error.main, 0.1), + width: 42, + height: 42, + }} + > + <MicRounded /> + </IconButton> + </motion.div> + ) : ( + <IconButton + onClick={onStartListening} + disabled={isStreaming} + aria-label="语音输入" + sx={{ color: "text.secondary", width: 42, height: 42 }} + > + <MicRounded /> + </IconButton> + )} + </Box> + ) : null} + + <Box sx={{ pr: 0.5 }}> + <AnimatePresence mode="wait"> + {isStreaming ? ( + <motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> + <IconButton + onClick={onAbort} + aria-label="停止生成" + sx={{ + bgcolor: alpha(theme.palette.error.main, 0.1), + color: "error.main", + width: 42, + height: 42, + }} + > + <StopRounded /> + </IconButton> + </motion.div> + ) : ( + <motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> + <IconButton + disabled={!canSend} + onClick={onSend} + aria-label="发送" + sx={{ + bgcolor: canSend ? "primary.main" : "action.disabledBackground", + color: "#fff", + width: 42, + height: 42, + "&:hover": { bgcolor: canSend ? "primary.dark" : "action.disabledBackground" }, + }} + > + <SendRounded sx={{ ml: 0.35 }} /> + </IconButton> + </motion.div> + )} + </AnimatePresence> + </Box> + </Stack> + </motion.div> + </Box> + ); +}; diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx new file mode 100644 index 0000000..472ce3a --- /dev/null +++ b/src/components/chat/AgentHeader.tsx @@ -0,0 +1,158 @@ +"use client"; + +import React from "react"; +import { motion } from "framer-motion"; +import { + Avatar, + Box, + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; +import CloseRounded from "@mui/icons-material/CloseRounded"; + +type AgentHeaderProps = { + isStreaming: boolean; + menuAnchorEl: HTMLElement | null; + onMenuOpen: (event: React.MouseEvent<HTMLElement>) => void; + onMenuClose: () => void; + onNewConversation: () => void; + onClose: () => void; +}; + +export const AgentHeader = ({ + isStreaming, + menuAnchorEl, + onMenuOpen, + onMenuClose, + onNewConversation, + onClose, +}: AgentHeaderProps) => { + const theme = useTheme(); + const isMenuOpen = Boolean(menuAnchorEl); + + return ( + <Box + sx={{ + px: 3, + py: 2.5, + zIndex: 10, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }} + > + <Stack direction="row" alignItems="center" spacing={2}> + <motion.div whileHover={{ rotate: 10, scale: 1.08 }} whileTap={{ scale: 0.95 }}> + <IconButton + onClick={onMenuOpen} + aria-label="打开 Agent 菜单" + aria-controls={isMenuOpen ? "global-chatbox-header-menu" : undefined} + aria-expanded={isMenuOpen ? "true" : undefined} + aria-haspopup="menu" + sx={{ p: 0, borderRadius: "50%" }} + > + <Box sx={{ position: "relative" }}> + <Avatar + sx={{ + background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`, + boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`, + width: 48, + height: 48, + }} + > + <AutoAwesome fontSize="medium" sx={{ color: "#fff" }} /> + </Avatar> + <Box + sx={{ + position: "absolute", + bottom: 2, + right: 2, + width: 12, + height: 12, + bgcolor: isStreaming ? "warning.main" : "success.main", + borderRadius: "50%", + border: "2px solid #fff", + }} + /> + </Box> + </IconButton> + </motion.div> + <Box> + <Typography + variant="h6" + fontWeight={900} + sx={{ + background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, + backgroundClip: "text", + color: "transparent", + letterSpacing: -0.5, + }} + > + TJWater Agent + </Typography> + <Typography variant="caption" color="text.secondary" fontWeight={600}> + {isStreaming ? "正在分析管网任务" : "管网分析工作台"} + </Typography> + </Box> + </Stack> + + <Menu + id="global-chatbox-header-menu" + anchorEl={menuAnchorEl} + open={isMenuOpen} + onClose={onMenuClose} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + slotProps={{ + paper: { + elevation: 8, + sx: { + mt: 1, + minWidth: 180, + borderRadius: 3, + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + backdropFilter: "blur(12px)", + bgcolor: alpha("#fff", 0.92), + }, + }, + }} + > + <MenuItem onClick={onNewConversation}> + <ListItemIcon> + <AddCommentRounded fontSize="small" /> + </ListItemIcon> + <ListItemText + primary="新建对话" + secondary="清空当前会话" + primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 700 } }} + secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }} + /> + </MenuItem> + </Menu> + + <motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }}> + <IconButton + onClick={onClose} + size="small" + aria-label="关闭 Agent" + sx={{ + color: "text.primary", + bgcolor: alpha("#fff", 0.54), + "&:hover": { bgcolor: "#fff" }, + }} + > + <CloseRounded /> + </IconButton> + </motion.div> + </Box> + ); +}; diff --git a/src/components/chat/AgentProgressTimeline.test.tsx b/src/components/chat/AgentProgressTimeline.test.tsx new file mode 100644 index 0000000..60cf369 --- /dev/null +++ b/src/components/chat/AgentProgressTimeline.test.tsx @@ -0,0 +1,60 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { AgentProgressTimeline } from "./AgentProgressTimeline"; +import type { ChatProgress } from "./GlobalChatbox.types"; + +describe("AgentProgressTimeline", () => { + it("shows the running step and keeps the timeline expanded while running", () => { + const progress: ChatProgress[] = [ + { + id: "start", + phase: "start", + status: "completed", + title: "收到请求", + }, + { + id: "tool", + phase: "tool", + status: "running", + title: "正在调用 dynamic_http_call", + detail: "GET /api/v1/network/bottlenecks", + }, + ]; + + render(<AgentProgressTimeline progress={progress} />); + + expect(screen.getByText("Agent 过程")).toBeInTheDocument(); + expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument(); + expect(screen.getByText("查询后端数据")).toBeInTheDocument(); + expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument(); + }); + + it("summarizes completed steps and lets users expand details", async () => { + const progress: ChatProgress[] = [ + { id: "start", phase: "start", status: "completed", title: "收到请求" }, + { id: "done", phase: "complete", status: "completed", title: "分析完成" }, + ]; + + render(<AgentProgressTimeline progress={progress} />); + + expect(screen.getByText("已完成 2 步")).toBeInTheDocument(); + expect(screen.queryByText("分析完成")).not.toBeVisible(); + + fireEvent.click(screen.getByRole("button", { name: "展开" })); + + expect(screen.getByText("分析完成")).toBeVisible(); + }); + + it("treats stale running steps as finished after a complete event", () => { + const progress: ChatProgress[] = [ + { id: "tool", phase: "tool", status: "running", title: "正在调用 dynamic_http_call" }, + { id: "done", phase: "complete", status: "completed", title: "分析完成" }, + ]; + + render(<AgentProgressTimeline progress={progress} />); + + expect(screen.getByText("已完成 2 步")).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx new file mode 100644 index 0000000..fc92ef9 --- /dev/null +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -0,0 +1,188 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { + Box, + Button, + Chip, + Collapse, + LinearProgress, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded"; +import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded"; +import TaskAltRounded from "@mui/icons-material/TaskAltRounded"; +import PsychologyRounded from "@mui/icons-material/PsychologyRounded"; +import SyncRounded from "@mui/icons-material/SyncRounded"; + +import type { ChatProgress } from "./GlobalChatbox.types"; + +const phaseIcon = (phase: string, status: ChatProgress["status"]) => { + const sx = { fontSize: 16 }; + if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />; + if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />; + if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "primary.main" }} />; + if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />; + if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />; + if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />; + if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "primary.main" }} />; + return <AutoAwesome sx={{ ...sx, color: "primary.main" }} />; +}; + +const formatToolTitle = (item: ChatProgress) => { + const text = `${item.title} ${item.detail ?? ""}`; + if (text.includes("dynamic_http_call")) return "查询后端数据"; + if (text.includes("show_chart")) return "生成图表"; + if (text.includes("locate_features")) return "地图定位"; + if (text.includes("view_history")) return "打开历史曲线"; + if (text.includes("view_scada")) return "打开 SCADA 面板"; + return item.title; +}; + +export const AgentProgressTimeline = ({ progress }: { progress: ChatProgress[] }) => { + const theme = useTheme(); + const hasComplete = progress.some( + (item) => item.phase === "complete" && item.status === "completed", + ); + const hasRunning = + !hasComplete && progress.some((item) => item.status === "running"); + const hasError = progress.some((item) => item.status === "error"); + const [expanded, setExpanded] = useState(hasRunning); + + const summary = useMemo(() => { + const completedCount = progress.filter((item) => item.status === "completed").length; + const runningItem = hasComplete + ? undefined + : [...progress].reverse().find((item) => item.status === "running"); + if (runningItem) return runningItem.title; + if (hasError) return "过程存在异常"; + if (hasComplete) return `已完成 ${progress.length} 步`; + return `已完成 ${completedCount || progress.length} 步`; + }, [hasComplete, hasError, progress]); + + return ( + <Box + sx={{ + borderRadius: 3, + bgcolor: alpha(theme.palette.primary.main, 0.045), + border: `1px solid ${alpha(theme.palette.primary.main, 0.14)}`, + overflow: "hidden", + }} + > + <Stack + direction="row" + spacing={1} + alignItems="center" + sx={{ px: 1.5, py: 1.1 }} + > + <AutoAwesome sx={{ fontSize: 17, color: "primary.main" }} /> + <Typography variant="caption" fontWeight={800} color="text.primary"> + Agent 过程 + </Typography> + <Chip + size="small" + label={summary} + color={hasError ? "error" : hasRunning ? "primary" : "success"} + variant="outlined" + sx={{ height: 22, fontSize: "0.68rem", maxWidth: 180 }} + /> + <Box sx={{ flex: 1 }} /> + <Button + size="small" + onClick={() => setExpanded((value) => !value)} + sx={{ minWidth: 0, px: 0.75, fontSize: "0.72rem" }} + > + {expanded ? "收起" : "展开"} + </Button> + </Stack> + {hasRunning ? <LinearProgress sx={{ height: 3 }} /> : null} + <Collapse in={expanded} timeout="auto"> + <Stack spacing={1} sx={{ px: 1.5, pb: 1.35 }}> + {progress.map((item, index) => ( + <Stack key={item.id} direction="row" spacing={1} alignItems="stretch"> + <Box + sx={{ + position: "relative", + width: 18, + display: "flex", + justifyContent: "center", + flexShrink: 0, + pt: 0.1, + }} + > + {index < progress.length - 1 ? ( + <Box + aria-hidden + sx={{ + position: "absolute", + top: 18, + bottom: -10, + left: "50%", + width: 2, + transform: "translateX(-50%)", + borderRadius: 99, + bgcolor: alpha( + item.status === "error" + ? theme.palette.error.main + : theme.palette.primary.main, + item.status === "completed" ? 0.22 : 0.36, + ), + }} + /> + ) : null} + <Box + sx={{ + position: "relative", + zIndex: 1, + width: 18, + height: 18, + borderRadius: "50%", + bgcolor: alpha("#fff", 0.92), + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {phaseIcon( + item.phase, + hasComplete && item.status === "running" + ? "completed" + : item.status, + )} + </Box> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="caption" color="text.primary" fontWeight={700}> + {item.phase === "tool" ? formatToolTitle(item) : item.title} + </Typography> + {item.detail ? ( + <Typography + variant="caption" + component="pre" + color="text.secondary" + sx={{ + display: "block", + mt: 0.25, + m: 0, + whiteSpace: "pre-wrap", + fontFamily: "inherit", + fontSize: "0.7rem", + }} + > + {item.detail} + </Typography> + ) : null} + </Box> + </Stack> + ))} + </Stack> + </Collapse> + </Box> + ); +}; diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx new file mode 100644 index 0000000..ff94738 --- /dev/null +++ b/src/components/chat/AgentTurn.tsx @@ -0,0 +1,299 @@ +"use client"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { motion } from "framer-motion"; +import { + Avatar, + Box, + IconButton, + Paper, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; +import PauseRounded from "@mui/icons-material/PauseRounded"; +import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; +import StopRounded from "@mui/icons-material/StopRounded"; + +import { AgentArtifactPanel } from "./AgentArtifactPanel"; +import { AgentProgressTimeline } from "./AgentProgressTimeline"; +import { ChatInlineChart } from "./ChatInlineChart"; +import type { ChatChartSeries } from "./ChatInlineChart"; +import { ChatToolCallBlock } from "./ChatToolCallBlock"; +import { + parseAssistantMessageSections, + parseContentWithToolCalls, + type ContentSegment, +} from "./chatMessageSections"; +import markdownStyles from "./GlobalChatboxMarkdown.module.css"; +import type { Message, SpeechState } from "./GlobalChatbox.types"; +import { stripMarkdown } from "./GlobalChatbox.utils"; + +type AgentTurnProps = { + message: Message; + messageSpeechState: SpeechState; + onSpeak: (messageId: string, text: string) => void; + onPause: () => void; + onResume: () => void; + onStopSpeech: () => void; + isTtsSupported: boolean; +}; + +const MarkdownBlock = ({ children }: { children: string }) => ( + <div className={markdownStyles.markdown}> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> + </div> +); + +export const AgentTurn = React.memo( + ({ + message, + messageSpeechState, + onSpeak, + onPause, + onResume, + onStopSpeech, + isTtsSupported, + }: AgentTurnProps) => { + const theme = useTheme(); + const isUser = message.role === "user"; + const isErrorMessage = Boolean(message.isError); + const parsedAssistantSections = + !isUser && !isErrorMessage + ? parseAssistantMessageSections(message.content) + : null; + const answerContent = parsedAssistantSections?.answer ?? message.content; + const contentSegments: ContentSegment[] = + !isUser && !isErrorMessage + ? parseContentWithToolCalls(answerContent).segments + : [{ type: "text", content: answerContent }]; + + if (isUser) { + return ( + <motion.div + initial={{ opacity: 0, y: 12, scale: 0.98 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={{ opacity: 0, y: 8 }} + transition={{ type: "spring", stiffness: 350, damping: 25 }} + style={{ alignSelf: "flex-end", maxWidth: "86%" }} + > + <Paper + elevation={8} + sx={{ + p: 2, + borderRadius: 4, + borderBottomRightRadius: 1.5, + color: "#fff", + background: `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`, + boxShadow: `0 10px 28px -8px ${alpha(theme.palette.primary.main, 0.5)}`, + "--chat-md-text": alpha("#fff", 0.96), + "--chat-md-heading": "#fff", + "--chat-md-link": "#E3F2FD", + "--chat-md-link-hover": "#fff", + "--chat-md-inline-code-bg": "rgba(255,255,255,0.2)", + "--chat-md-inline-code-border": alpha("#fff", 0.16), + "--chat-md-inline-code-text": "#fff", + "--chat-md-pre-bg": "rgba(11, 18, 32, 0.56)", + "--chat-md-pre-border": alpha("#fff", 0.12), + "--chat-md-pre-text": "#F8FAFC", + "--chat-md-quote-border": alpha("#fff", 0.5), + "--chat-md-quote-bg": alpha("#fff", 0.08), + "--chat-md-quote-text": alpha("#fff", 0.9), + }} + > + <MarkdownBlock>{message.content}</MarkdownBlock> + </Paper> + </motion.div> + ); + } + + return ( + <motion.div + initial={{ opacity: 0, y: 14 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: 8 }} + transition={{ type: "spring", stiffness: 320, damping: 26 }} + style={{ width: "100%" }} + > + <Stack direction="row" spacing={1.25} alignItems="flex-start"> + <Avatar + sx={{ + width: 32, + height: 32, + bgcolor: isErrorMessage + ? alpha(theme.palette.error.main, 0.12) + : alpha(theme.palette.secondary.main, 0.12), + mt: 0.25, + }} + > + {isErrorMessage ? ( + <ErrorOutlineRounded sx={{ fontSize: 17, color: "error.main" }} /> + ) : ( + <AutoAwesome sx={{ fontSize: 17, color: "secondary.main" }} /> + )} + </Avatar> + + <Paper + elevation={0} + sx={{ + flex: 1, + minWidth: 0, + p: 1.5, + borderRadius: 4, + bgcolor: alpha("#fff", 0.84), + border: `1px solid ${alpha( + isErrorMessage ? theme.palette.error.main : theme.palette.divider, + isErrorMessage ? 0.34 : 0.16, + )}`, + boxShadow: `0 14px 40px -24px ${alpha(theme.palette.common.black, 0.32)}`, + "--chat-md-text": isErrorMessage ? theme.palette.error.dark : "#1f2937", + "--chat-md-heading": isErrorMessage ? theme.palette.error.dark : "#111827", + "--chat-md-link": isErrorMessage ? theme.palette.error.main : "#7C3AED", + "--chat-md-link-hover": isErrorMessage ? theme.palette.error.dark : "#6D28D9", + "--chat-md-inline-code-bg": isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#EEF2FF", + "--chat-md-inline-code-border": isErrorMessage + ? alpha(theme.palette.error.main, 0.25) + : "#CBD5E1", + "--chat-md-inline-code-text": isErrorMessage + ? theme.palette.error.dark + : "#334155", + "--chat-md-pre-bg": isErrorMessage + ? alpha(theme.palette.error.main, 0.08) + : "#111827", + "--chat-md-pre-border": isErrorMessage + ? alpha(theme.palette.error.main, 0.3) + : "#64748B", + "--chat-md-pre-text": isErrorMessage ? theme.palette.error.dark : "#E5E7EB", + "--chat-md-quote-border": isErrorMessage + ? alpha(theme.palette.error.main, 0.5) + : "#7C3AED", + "--chat-md-quote-bg": isErrorMessage + ? alpha(theme.palette.error.main, 0.06) + : "#F5F3FF", + "--chat-md-quote-text": isErrorMessage ? theme.palette.error.dark : "#475569", + }} + > + <Stack spacing={1.4}> + {message.progress?.length && !isErrorMessage ? ( + <AgentProgressTimeline progress={message.progress} /> + ) : null} + + <Box + sx={{ + p: 1.35, + borderRadius: 3, + bgcolor: isErrorMessage + ? alpha(theme.palette.error.main, 0.055) + : alpha("#fff", 0.72), + border: `1px solid ${alpha( + isErrorMessage ? theme.palette.error.main : theme.palette.divider, + isErrorMessage ? 0.18 : 0.12, + )}`, + }} + > + <Stack spacing={1}> + {!isErrorMessage ? ( + <Typography variant="caption" color="text.secondary" fontWeight={800}> + 回答 + </Typography> + ) : null} + {contentSegments.map((segment, segIdx) => { + if (segment.type === "text") { + const text = segment.content.trim(); + if (!text && contentSegments.length > 1) return null; + return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>; + } + if (segment.type === "tool_call") { + if ( + segment.toolCall.tool === "chart" || + segment.toolCall.tool === "show_chart" + ) { + const p = segment.toolCall.params; + return ( + <ChatInlineChart + key={segment.toolCall.id} + title={(p.title as string) ?? undefined} + chart_type={ + (p.chart_type as "line" | "bar" | "pie") ?? "line" + } + x_data={(p.x_data as string[]) ?? []} + series={(p.series as ChatChartSeries[]) ?? []} + x_axis_name={(p.x_axis_name as string) ?? undefined} + y_axis_name={(p.y_axis_name as string) ?? undefined} + /> + ); + } + return ( + <ChatToolCallBlock + key={segment.toolCall.id} + toolCall={segment.toolCall} + /> + ); + } + if (segment.type === "tool_call_pending") { + return ( + <Typography key="tool-pending" variant="caption" color="text.secondary"> + 正在准备工具调用... + </Typography> + ); + } + return null; + })} + </Stack> + </Box> + + {message.artifacts?.length ? ( + <AgentArtifactPanel artifacts={message.artifacts} /> + ) : null} + </Stack> + </Paper> + </Stack> + + {!isErrorMessage && isTtsSupported ? ( + <Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 5.4 }}> + {messageSpeechState === "idle" ? ( + <IconButton + size="small" + onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} + aria-label="朗读消息" + sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} + > + <VolumeUpRounded sx={{ fontSize: 16 }} /> + </IconButton> + ) : null} + {messageSpeechState === "playing" ? ( + <> + <IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> + <PauseRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + ) : null} + {messageSpeechState === "paused" ? ( + <> + <IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}> + <PlayArrowRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + ) : null} + </Stack> + ) : null} + </motion.div> + ); + }, +); + +AgentTurn.displayName = "AgentTurn"; diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx new file mode 100644 index 0000000..208891f --- /dev/null +++ b/src/components/chat/AgentWorkspace.tsx @@ -0,0 +1,177 @@ +"use client"; + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Box, Paper, Stack, Typography, alpha, useTheme } from "@mui/material"; +import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; +import SensorsRounded from "@mui/icons-material/SensorsRounded"; +import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; + +import { AgentTurn } from "./AgentTurn"; +import { TypingIndicator } from "./GlobalChatbox.parts"; +import type { Message, SpeechState } from "./GlobalChatbox.types"; + +type AgentWorkspaceProps = { + messages: Message[]; + isStreaming: boolean; + bottomRef: React.RefObject<HTMLDivElement | null>; + speakingMessageId: string | null; + speechState: SpeechState; + onSpeak: (messageId: string, text: string) => void; + onPauseSpeech: () => void; + onResumeSpeech: () => void; + onStopSpeech: () => void; + isTtsSupported: boolean; +}; + +const EmptyState = () => { + const theme = useTheme(); + const capabilities = [ + { icon: <WaterDropRounded sx={{ fontSize: 18 }} />, label: "水力瓶颈识别" }, + { icon: <SensorsRounded sx={{ fontSize: 18 }} />, label: "SCADA 异常分析" }, + { icon: <TroubleshootRounded sx={{ fontSize: 18 }} />, label: "改造与调度建议" }, + ]; + + return ( + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ type: "spring", stiffness: 200, damping: 20 }} + style={{ margin: "auto", width: "100%" }} + > + <Paper + elevation={0} + sx={{ + p: 3, + borderRadius: 5, + bgcolor: alpha("#fff", 0.68), + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + maxWidth: 380, + mx: "auto", + textAlign: "center", + backdropFilter: "blur(10px)", + }} + > + <motion.div + animate={{ y: [-5, 5, -5] }} + transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }} + > + <AutoAwesome + sx={{ + fontSize: 54, + color: "primary.main", + mb: 1.6, + filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))", + }} + /> + </motion.div> + <Typography variant="h6" color="text.primary" fontWeight={900} gutterBottom> + 管网分析 Agent 已就绪 + </Typography> + <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.65, mb: 2 }}> + 可以描述你的分析目标,我会展示规划、数据查询过程、地图动作和最终建议。 + </Typography> + <Stack direction="row" spacing={0.8} useFlexGap flexWrap="wrap" justifyContent="center"> + {capabilities.map((item) => ( + <Stack + key={item.label} + direction="row" + spacing={0.5} + alignItems="center" + sx={{ + px: 1, + py: 0.55, + borderRadius: 99, + bgcolor: alpha(theme.palette.primary.main, 0.07), + color: "text.secondary", + }} + > + {item.icon} + <Typography variant="caption" fontWeight={700}> + {item.label} + </Typography> + </Stack> + ))} + </Stack> + </Paper> + </motion.div> + ); +}; + +export const AgentWorkspace = ({ + messages, + isStreaming, + bottomRef, + speakingMessageId, + speechState, + onSpeak, + onPauseSpeech, + onResumeSpeech, + onStopSpeech, + isTtsSupported, +}: AgentWorkspaceProps) => { + const theme = useTheme(); + const latestAssistant = [...messages] + .reverse() + .find((message) => message.role === "assistant"); + const showTypingIndicator = + isStreaming && + (!latestAssistant || + (latestAssistant.content.trim().length === 0 && + !(latestAssistant.artifacts?.length))); + + return ( + <Box + sx={{ + flex: 1, + overflowY: "auto", + px: 2.5, + py: 2, + display: "flex", + flexDirection: "column", + gap: 2, + zIndex: 5, + }} + > + <AnimatePresence initial={false}> + {messages.length === 0 ? <EmptyState /> : null} + {messages.map((message) => ( + <AgentTurn + key={message.id} + message={message} + messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} + onSpeak={onSpeak} + onPause={onPauseSpeech} + onResume={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + /> + ))} + </AnimatePresence> + + {showTypingIndicator ? ( + <motion.div + initial={{ opacity: 0, y: 10, scale: 0.94 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + transition={{ type: "spring", stiffness: 300 }} + style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }} + > + <Paper + elevation={0} + sx={{ + p: 1.3, + borderRadius: 4, + bgcolor: alpha("#fff", 0.82), + boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`, + }} + > + <TypingIndicator /> + </Paper> + </motion.div> + ) : null} + + <div ref={bottomRef} style={{ height: 1 }} /> + </Box> + ); +}; diff --git a/src/components/chat/GlobalChatbox.parts.tsx b/src/components/chat/GlobalChatbox.parts.tsx index 5b03fb8..780f839 100644 --- a/src/components/chat/GlobalChatbox.parts.tsx +++ b/src/components/chat/GlobalChatbox.parts.tsx @@ -1,39 +1,8 @@ "use client"; import React from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { motion } from "framer-motion"; -import { - Avatar, - Box, - Chip, - IconButton, - LinearProgress, - Paper, - Stack, - Typography, - alpha, -} from "@mui/material"; -import type { Theme } from "@mui/material/styles"; -import AutoAwesome from "@mui/icons-material/AutoAwesome"; -import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; -import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; -import HourglassEmptyRounded from "@mui/icons-material/HourglassEmptyRounded"; -import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; -import PauseRounded from "@mui/icons-material/PauseRounded"; -import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; -import StopRounded from "@mui/icons-material/StopRounded"; -import { - parseAssistantMessageSections, - parseContentWithToolCalls, - type ContentSegment, -} from "./chatMessageSections"; -import { ChatInlineChart } from "./ChatInlineChart"; -import { ChatToolCallBlock } from "./ChatToolCallBlock"; -import markdownStyles from "./GlobalChatboxMarkdown.module.css"; -import type { ChatProgress, Message, SpeechState } from "./GlobalChatbox.types"; -import { stripMarkdown } from "./GlobalChatbox.utils"; +import { Box, Stack } from "@mui/material"; export const TypingIndicator = () => { return ( @@ -105,401 +74,3 @@ export const Blob = ({ }} /> ); - -type ChatMessageItemProps = { - message: Message; - theme: Theme; - messageSpeechState: SpeechState; - onSpeak: (messageId: string, text: string) => void; - onPause: () => void; - onResume: () => void; - onStopSpeech: () => void; - isTtsSupported: boolean; - sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>; -}; - -export const ChatMessageItem = React.memo( - ({ - message, - theme, - messageSpeechState, - onSpeak, - onPause, - onResume, - onStopSpeech, - isTtsSupported, - sseChartParams, - }: ChatMessageItemProps) => { - const isUser = message.role === "user"; - const isErrorMessage = Boolean(message.isError); - const parsedAssistantSections = - !isUser && !isErrorMessage - ? parseAssistantMessageSections(message.content) - : null; - const answerContent = parsedAssistantSections?.answer ?? message.content; - - const contentSegments: ContentSegment[] = - !isUser && !isErrorMessage - ? parseContentWithToolCalls(answerContent).segments - : [{ type: "text", content: answerContent }]; - - return ( - <motion.div - initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - exit={{ opacity: 0, scale: 0.8 }} - transition={{ type: "spring", stiffness: 350, damping: 25 }} - style={{ - alignSelf: isUser ? "flex-end" : "flex-start", - maxWidth: "85%", - display: "flex", - flexDirection: isUser ? "row-reverse" : "row", - gap: 12, - alignItems: "flex-end", - }} - > - {!isUser && ( - <Avatar - sx={{ - width: 28, - height: 28, - bgcolor: isErrorMessage - ? alpha(theme.palette.error.main, 0.12) - : alpha(theme.palette.secondary.main, 0.1), - mb: 0.5, - }} - > - {isErrorMessage ? ( - <ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} /> - ) : ( - <AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} /> - )} - </Avatar> - )} - - <Box> - <Paper - elevation={isUser ? 8 : isErrorMessage ? 1 : 2} - sx={{ - p: 2.5, - borderRadius: 4, - borderBottomRightRadius: isUser ? 4 : 24, - borderBottomLeftRadius: !isUser ? 4 : 24, - bgcolor: isUser - ? "primary.main" - : isErrorMessage - ? alpha(theme.palette.error.light, 0.18) - : "#fff", - color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary", - background: isUser - ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` - : isErrorMessage - ? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})` - : undefined, - border: isErrorMessage - ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` - : "none", - boxShadow: isUser - ? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}` - : isErrorMessage - ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` - : `0 4px 16px -4px ${alpha("#000", 0.05)}`, - "--chat-md-text": isUser - ? alpha("#fff", 0.96) - : isErrorMessage - ? theme.palette.error.dark - : "#1f2937", - "--chat-md-heading": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#111827", - "--chat-md-link": isUser - ? "#E3F2FD" - : isErrorMessage - ? theme.palette.error.main - : "#7C3AED", - "--chat-md-link-hover": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#6D28D9", - "--chat-md-inline-code-bg": isUser - ? "rgba(255,255,255,0.2)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#EEF2FF", - "--chat-md-inline-code-border": isUser - ? alpha("#fff", 0.16) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.25) - : "#CBD5E1", - "--chat-md-inline-code-text": isUser - ? "#fff" - : isErrorMessage - ? theme.palette.error.dark - : "#334155", - "--chat-md-pre-bg": isUser - ? "rgba(11, 18, 32, 0.56)" - : isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#111827", - "--chat-md-pre-border": isUser - ? alpha("#fff", 0.12) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.3) - : "#64748B", - "--chat-md-pre-text": isUser - ? "#F8FAFC" - : isErrorMessage - ? theme.palette.error.dark - : "#E5E7EB", - "--chat-md-quote-border": isErrorMessage - ? alpha(theme.palette.error.main, 0.5) - : isUser - ? alpha("#fff", 0.5) - : "#7C3AED", - "--chat-md-quote-bg": isUser - ? alpha("#fff", 0.08) - : isErrorMessage - ? alpha(theme.palette.error.main, 0.06) - : "#F5F3FF", - "--chat-md-quote-text": isUser - ? alpha("#fff", 0.9) - : isErrorMessage - ? theme.palette.error.dark - : "#475569", - }} - > - {!isUser && !isErrorMessage && message.progress?.length ? ( - <ChatProgressPanel progress={message.progress} /> - ) : null} - {contentSegments.map((segment, segIdx) => { - if (segment.type === "text") { - const text = segment.content.trim(); - if (!text && contentSegments.length > 1) return null; - return ( - <div key={segIdx} className={markdownStyles.markdown}> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {text || "..."} - </ReactMarkdown> - </div> - ); - } - if (segment.type === "tool_call") { - if (segment.toolCall.tool === "chart") { - return ( - <ChatInlineChart - key={segment.toolCall.id} - {...(segment.toolCall.params as Record<string, unknown>)} - /> - ); - } - if (segment.toolCall.tool === "show_chart") { - const p = segment.toolCall.params; - return ( - <ChatInlineChart - key={segment.toolCall.id} - title={(p.title as string) ?? undefined} - chart_type={ - (p.chart_type as "line" | "bar" | "pie") ?? "line" - } - x_data={(p.x_data as string[]) ?? []} - series={ - (p.series as import("./ChatInlineChart").ChatChartSeries[]) ?? - [] - } - x_axis_name={(p.x_axis_name as string) ?? undefined} - y_axis_name={(p.y_axis_name as string) ?? undefined} - /> - ); - } - return ( - <ChatToolCallBlock - key={segment.toolCall.id} - toolCall={segment.toolCall} - /> - ); - } - if (segment.type === "tool_call_pending") { - return ( - <motion.div - key="tool-pending" - initial={{ opacity: 0 }} - animate={{ opacity: [0.4, 1, 0.4] }} - transition={{ - duration: 1.5, - repeat: Infinity, - ease: "easeInOut", - }} - style={{ - marginTop: 8, - display: "flex", - alignItems: "center", - gap: 8, - }} - > - <AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} /> - <Typography variant="caption" color="text.secondary"> - 正在准备工具调用... - </Typography> - </motion.div> - ); - } - return null; - })} - {sseChartParams?.map((chart, idx) => ( - <ChatInlineChart - key={`sse-chart-${idx}`} - title={(chart.params.title as string) ?? undefined} - chart_type={ - (chart.params.chart_type as "line" | "bar" | "pie") ?? "line" - } - x_data={(chart.params.x_data as string[]) ?? []} - series={ - (chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ?? - [] - } - x_axis_name={(chart.params.x_axis_name as string) ?? undefined} - y_axis_name={(chart.params.y_axis_name as string) ?? undefined} - /> - ))} - </Paper> - {!isUser && !isErrorMessage && isTtsSupported && ( - <Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}> - {messageSpeechState === "idle" && ( - <IconButton - size="small" - onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} - aria-label="朗读消息" - sx={{ - color: "text.secondary", - opacity: 0.6, - "&:hover": { opacity: 1 }, - p: 0.5, - }} - > - <VolumeUpRounded sx={{ fontSize: 16 }} /> - </IconButton> - )} - {messageSpeechState === "playing" && ( - <> - <IconButton - size="small" - onClick={onPause} - aria-label="暂停朗读" - sx={{ color: "primary.main", p: 0.5 }} - > - <PauseRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton - size="small" - onClick={onStopSpeech} - aria-label="停止朗读" - sx={{ color: "error.main", p: 0.5 }} - > - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> - )} - {messageSpeechState === "paused" && ( - <> - <IconButton - size="small" - onClick={onResume} - aria-label="继续朗读" - sx={{ color: "primary.main", p: 0.5 }} - > - <PlayArrowRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton - size="small" - onClick={onStopSpeech} - aria-label="停止朗读" - sx={{ color: "error.main", p: 0.5 }} - > - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> - )} - </Stack> - )} - </Box> - </motion.div> - ); - }, -); - -ChatMessageItem.displayName = "ChatMessageItem"; - -const ChatProgressPanel = ({ progress }: { progress: ChatProgress[] }) => { - const isComplete = progress.some( - (item) => item.phase === "complete" && item.status === "completed", - ); - const latestRunning = isComplete - ? undefined - : [...progress].reverse().find((item) => item.status === "running"); - return ( - <Box - sx={{ - mb: 1.5, - p: 1.25, - borderRadius: 2.5, - bgcolor: "rgba(99, 102, 241, 0.06)", - border: "1px solid rgba(99, 102, 241, 0.14)", - }} - > - <Stack spacing={1}> - <Stack direction="row" spacing={1} alignItems="center"> - <AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} /> - <Typography variant="caption" fontWeight={800} color="text.primary"> - Agent 过程 - </Typography> - {latestRunning ? ( - <Chip - size="small" - label={latestRunning.title} - sx={{ height: 20, fontSize: "0.68rem", bgcolor: "rgba(124, 58, 237, 0.08)" }} - /> - ) : null} - </Stack> - {latestRunning ? <LinearProgress sx={{ height: 4, borderRadius: 99 }} /> : null} - <Stack spacing={0.7}> - {progress.slice(-5).map((item) => ( - <Stack key={item.id} direction="row" spacing={0.8} alignItems="flex-start"> - {item.status === "completed" ? ( - <CheckCircleRounded sx={{ fontSize: 15, color: "success.main", mt: 0.2 }} /> - ) : item.status === "error" ? ( - <ErrorOutlineRounded sx={{ fontSize: 15, color: "error.main", mt: 0.2 }} /> - ) : ( - <HourglassEmptyRounded sx={{ fontSize: 15, color: "primary.main", mt: 0.2 }} /> - )} - <Box sx={{ minWidth: 0 }}> - <Typography variant="caption" color="text.primary" fontWeight={700}> - {item.title} - </Typography> - {item.detail ? ( - <Typography - variant="caption" - component="pre" - color="text.secondary" - sx={{ - display: "block", - mt: 0.25, - m: 0, - whiteSpace: "pre-wrap", - fontFamily: "inherit", - fontSize: "0.7rem", - }} - > - {item.detail} - </Typography> - ) : null} - </Box> - </Stack> - ))} - </Stack> - </Stack> - </Box> - ); -}; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 4868595..0e1af3c 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -1,83 +1,28 @@ "use client"; -import React, { useMemo, useRef, useState, useEffect, useCallback } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Drawer, alpha, useTheme } from "@mui/material"; -// MUI -import { - Avatar, - Box, - Drawer, - IconButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - Paper, - Stack, - TextField, - Typography, - useTheme, - alpha, -} from "@mui/material"; - -// Icons -import CloseRounded from "@mui/icons-material/CloseRounded"; -import SendRounded from "@mui/icons-material/SendRounded"; -import StopRounded from "@mui/icons-material/StopRounded"; -import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI -import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; -import MicRounded from "@mui/icons-material/MicRounded"; -import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; -import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; - -// Logic -import { streamAgentChat } from "@/lib/chatStream"; -import type { StreamEvent } from "@/lib/chatStream"; -import { - useChatToolStore, - type ChatToolAction, -} from "@/store/chatToolStore"; -import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types"; -import { - CHAT_STORAGE_KEY, - PRESET_PROMPTS, - createId, - getInitialChatState, - normalizeThoughtTagToken, -} from "./GlobalChatbox.utils"; -import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts"; +import { AgentComposer } from "./AgentComposer"; +import { AgentHeader } from "./AgentHeader"; +import { AgentWorkspace } from "./AgentWorkspace"; +import { Blob } from "./GlobalChatbox.parts"; +import type { Props } from "./GlobalChatbox.types"; +import { PRESET_PROMPTS } from "./GlobalChatbox.utils"; import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"; +import { useAgentChatSession } from "./hooks/useAgentChatSession"; +import { useAgentToolActions } from "./hooks/useAgentToolActions"; export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { - const initialChatStateRef = useRef<PersistedChatState | null>(null); - if (initialChatStateRef.current === null) { - initialChatStateRef.current = getInitialChatState(); - } - - const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages); const [input, setInput] = useState(""); - const [isStreaming, setIsStreaming] = useState(false); - const [width, setWidth] = useState(480); + const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); - const [sessionId, setSessionId] = useState<string | undefined>( - initialChatStateRef.current.sessionId - ); const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null); - const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false); - // SSE tool_call → inline chart data (keyed by assistantMessageId) - const [sseCharts, setSseCharts] = useState< - Record<string, Array<{ tool: string; params: Record<string, unknown> }>> - >({}); - - const dispatchToolAction = useChatToolStore((s) => s.dispatch); - const abortRef = useRef<AbortController | null>(null); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); const theme = useTheme(); - // --- Voice Features --- const { speechState, speakingMessageId, @@ -99,10 +44,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { isSupported: isSttSupported, } = useSpeechRecognition(handleSpeechResult); - const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); - const isHeaderMenuOpen = Boolean(headerMenuAnchorEl); + const handleToolCall = useAgentToolActions(); + const { + messages, + isStreaming, + sendPrompt, + abort, + reset, + } = useAgentChatSession({ + onToolCall: handleToolCall, + onBeforeSend: stopListening, + }); - // Auto-scroll useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isStreaming]); @@ -116,337 +69,49 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { return () => window.clearTimeout(timer); }, [open]); - useEffect(() => { - const state: PersistedChatState = { messages, sessionId }; - try { - window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); - } catch (error) { - console.error("[GlobalChatbox] Failed to persist chat state:", error); - } - }, [messages, sessionId]); - - const sendPrompt = useCallback( - async (rawPrompt: string) => { - const prompt = rawPrompt.trim(); - if (!prompt || isStreaming) return; - stopListening(); - - const userId = createId(); - const assistantId = createId(); - setInput(""); - setIsStreaming(true); - - setMessages((prev) => [ - ...prev, - { id: userId, role: "user", content: prompt }, - { id: assistantId, role: "assistant", content: "" }, - ]); - - const controller = new AbortController(); - abortRef.current = controller; - - // Track SSE tool_call hashes to deduplicate against text-parsed tool_calls - const sseToolHashes = new Set<string>(); - - const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => { - const { tool, params } = event; - const hash = `${tool}:${JSON.stringify(params)}`; - sseToolHashes.add(hash); - const startTime = - (params.start_time as string | undefined) ?? - (params.startTime as string | undefined) ?? - (params.from as string | undefined) ?? - (params.start as string | undefined); - const endTime = - (params.end_time as string | undefined) ?? - (params.endTime as string | undefined) ?? - (params.to as string | undefined) ?? - (params.end as string | undefined); - const resolveScadaFeatureInfos = (): [string, string][] => { - const rawFeatureInfos = params.feature_infos; - if (Array.isArray(rawFeatureInfos)) { - const normalizedFeatureInfos = rawFeatureInfos - .map((item) => (Array.isArray(item) ? item : null)) - .filter((item): item is [unknown, unknown] => Boolean(item)) - .map( - (item) => - [String(item[0] ?? ""), String(item[1] ?? "scada")] as [ - string, - string, - ], - ) - .filter(([id]) => id.trim().length > 0); - if (normalizedFeatureInfos.length > 0) { - return normalizedFeatureInfos; - } - } - const rawDeviceIds = - params.device_ids ?? - params.deviceId ?? - params.device_id ?? - params.id ?? - params.ids; - const deviceIds = Array.isArray(rawDeviceIds) - ? rawDeviceIds.map((id) => String(id)) - : typeof rawDeviceIds === "string" - ? rawDeviceIds - .split(",") - .map((id) => id.trim()) - .filter(Boolean) - : []; - return deviceIds.map((id) => [id, "scada"]); - }; - - // show_chart → store as inline chart for rendering - if (tool === "show_chart") { - setSseCharts((prev) => ({ - ...prev, - [assistantId]: [ - ...(prev[assistantId] ?? []), - { tool, params }, - ], - })); - return; - } - - // Other frontend tools → dispatch to chatToolStore immediately - const normalizeIds = (): string[] => { - const rawIds = params.ids; - if (Array.isArray(rawIds)) { - return rawIds - .map((id) => String(id).trim()) - .filter(Boolean); - } - if (typeof rawIds === "string") { - return rawIds - .split(",") - .map((id) => id.trim()) - .filter(Boolean); - } - return []; - }; - const buildLocateFeaturesAction = ( - layer: string, - geometryKind: "point" | "line", - ): ChatToolAction => ({ - type: "locate_features" as const, - ids: normalizeIds(), - layer, - geometryKind, - }); - const buildLocateByFeatureType = (): ChatToolAction | null => { - const rawType = params.feature_type; - const featureType = - typeof rawType === "string" ? rawType.trim().toLowerCase() : ""; - const featureTypeMap: Record< - string, - { layer: string; geometryKind: "point" | "line" } - > = { - junction: { layer: "geo_junctions_mat", geometryKind: "point" }, - junctions: { layer: "geo_junctions_mat", geometryKind: "point" }, - pipe: { layer: "geo_pipes_mat", geometryKind: "line" }, - pipes: { layer: "geo_pipes_mat", geometryKind: "line" }, - valve: { layer: "geo_valves", geometryKind: "point" }, - valves: { layer: "geo_valves", geometryKind: "point" }, - reservoir: { layer: "geo_reservoirs", geometryKind: "point" }, - reservoirs: { layer: "geo_reservoirs", geometryKind: "point" }, - pump: { layer: "geo_pumps", geometryKind: "point" }, - pumps: { layer: "geo_pumps", geometryKind: "point" }, - tank: { layer: "geo_tanks", geometryKind: "point" }, - tanks: { layer: "geo_tanks", geometryKind: "point" }, - }; - const config = featureTypeMap[featureType]; - if (!config) return null; - return buildLocateFeaturesAction(config.layer, config.geometryKind); - }; - const actionMap: Record<string, () => ChatToolAction | null> = { - locate_features: buildLocateByFeatureType, - locate_pipes: () => buildLocateFeaturesAction("geo_pipes_mat", "line"), - locate_junctions: () => - buildLocateFeaturesAction("geo_junctions_mat", "point"), - locate_valves: () => buildLocateFeaturesAction("geo_valves", "point"), - locate_reservoirs: () => - buildLocateFeaturesAction("geo_reservoirs", "point"), - locate_pumps: () => buildLocateFeaturesAction("geo_pumps", "point"), - locate_tanks: () => buildLocateFeaturesAction("geo_tanks", "point"), - view_history: () => ({ - type: "view_history" as const, - featureInfos: (params.feature_infos as [string, string][]) ?? [], - dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime", - startTime, - endTime, - }), - view_scada: () => ({ - type: "view_scada" as const, - featureInfos: resolveScadaFeatureInfos(), - startTime, - endTime, - }), - }; - const buildAction = actionMap[tool]; - if (buildAction) { - const action = buildAction(); - if (action) dispatchToolAction(action); - } - }; - - try { - await streamAgentChat({ - message: prompt, - sessionId, - signal: controller.signal, - onEvent: (event) => { - if (event.type === "token") { - if (!sessionId && event.sessionId) setSessionId(event.sessionId); - const normalizedToken = normalizeThoughtTagToken(event.content); - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { ...m, content: m.content + normalizedToken, isError: false } - : m - ) - ); - } else if (event.type === "done") { - if (!sessionId && event.sessionId) setSessionId(event.sessionId); - setMessages((prev) => - prev.map((m) => - m.id === assistantId && m.content.trim().length === 0 - ? { - ...m, - content: "⚠️ **错误:** Agent 未返回内容,请稍后重试。", - isError: true, - } - : m.id === assistantId - ? { - ...m, - progress: m.progress?.map((item) => - item.status === "running" - ? { ...item, status: "completed" as const } - : item, - ), - } - : m - ) - ); - setIsStreaming(false); - } else if (event.type === "progress") { - if (!sessionId && event.sessionId) setSessionId(event.sessionId); - setMessages((prev) => - prev.map((m) => { - if (m.id !== assistantId) return m; - const progress = [...(m.progress ?? [])]; - const index = progress.findIndex((item) => item.id === event.id); - const nextProgress = { - id: event.id, - phase: event.phase, - status: event.status, - title: event.title, - detail: event.detail, - }; - if (index >= 0) { - progress[index] = nextProgress; - } else { - progress.push(nextProgress); - } - return { ...m, progress }; - }) - ); - } else if (event.type === "error") { - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - content: m.content || `⚠️ **错误:** ${event.message}`, - isError: true, - } - : m - ) - ); - setIsStreaming(false); - } else if (event.type === "tool_call") { - handleSseToolCall(event); - } - }, - }); - } catch (error) { - if (abortRef.current?.signal.aborted) { - setMessages((prev) => - prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0)) - ); - return; - } - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true } - : m - ) - ); - setIsStreaming(false); - } finally { - abortRef.current = null; - setIsStreaming(false); - } - }, - [sessionId, isStreaming, stopListening, dispatchToolAction], - ); - - const handleSend = async () => { + const handleSend = useCallback(() => { const prompt = input.trim(); if (!prompt || isStreaming) return; - await sendPrompt(prompt); - }; - - const handleAbort = () => { - abortRef.current?.abort(); - setIsStreaming(false); - }; + setInput(""); + void sendPrompt(prompt); + }, [input, isStreaming, sendPrompt]); const handlePresetPromptSelect = useCallback((prompt: string) => { setInput(prompt); - setIsPresetPanelOpen(false); window.setTimeout(() => { inputRef.current?.focus(); }, 0); }, []); - const handleHeaderMenuOpen = useCallback( - (event: React.MouseEvent<HTMLElement>) => { - setHeaderMenuAnchorEl(event.currentTarget); - }, - [], - ); + const handleHeaderMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>) => { + setHeaderMenuAnchorEl(event.currentTarget); + }, []); const handleHeaderMenuClose = useCallback(() => { setHeaderMenuAnchorEl(null); }, []); const handleNewConversation = useCallback(() => { - abortRef.current?.abort(); handleStopSpeech(); stopListening(); - setMessages([]); - setSessionId(undefined); + reset(); setInput(""); - setIsStreaming(false); handleHeaderMenuClose(); - window.setTimeout(() => { inputRef.current?.focus(); }, 0); - }, [handleHeaderMenuClose, handleStopSpeech, stopListening]); + }, [handleHeaderMenuClose, handleStopSpeech, reset, stopListening]); - const handleMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); + const handleMouseDown = useCallback((event: React.MouseEvent) => { + event.preventDefault(); setIsResizing(true); }, []); useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { + const handleMouseMove = (event: MouseEvent) => { if (!isResizing) return; - const newWidth = window.innerWidth - e.clientX; - if (newWidth > 320 && newWidth < 1200) { + const newWidth = window.innerWidth - event.clientX; + if (newWidth > 360 && newWidth < 1240) { setWidth(newWidth); } }; @@ -466,26 +131,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }; }, [isResizing]); - const renderedMessages = useMemo( - () => - messages.map((message) => ( - <ChatMessageItem - key={message.id} - message={message} - theme={theme} - messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} - onSpeak={handleSpeak} - onPause={handlePauseSpeech} - onResume={handleResumeSpeech} - onStopSpeech={handleStopSpeech} - isTtsSupported={isTtsSupported} - sseChartParams={sseCharts[message.id]} - /> - )), - [messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts], - ); - - return ( <Drawer anchor="right" @@ -499,9 +144,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { width: { xs: "100%", sm: width }, background: "transparent", boxShadow: "none", - overflow: "visible", // Changed from "hidden" to show resizer handle if needed, though handle is inside. + overflow: "visible", zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, - transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", // Disable transition during resize + transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", }, }} > @@ -510,12 +155,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { height: "100%", display: "flex", flexDirection: "column", - bgcolor: alpha("#fff", 0.75), // Light glass base + bgcolor: alpha("#fff", 0.76), backdropFilter: "blur(30px)", position: "relative", }} > - {/* Resize Handle */} <Box onMouseDown={handleMouseDown} sx={{ @@ -539,435 +183,50 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { height: "40px", bgcolor: alpha(theme.palette.divider, 0.4), borderRadius: "1px", - } + }, }} /> - {/* Ambient Blobs */} - <Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} /> - <Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} /> - <Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} /> + <Blob color={alpha(theme.palette.primary.main, 0.28)} size={300} top="-10%" left="-20%" delay={0} /> + <Blob color={alpha(theme.palette.secondary.main, 0.24)} size={250} top="40%" left="60%" delay={2} /> + <Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} /> - {/* Header - Transparent & Floating */} - <Box - sx={{ - p: 3, - zIndex: 10, - display: "flex", - alignItems: "center", - justifyContent: "space-between", - }} - > - <Stack direction="row" alignItems="center" spacing={2}> - <motion.div - whileHover={{ rotate: 10, scale: 1.1 }} - whileTap={{ scale: 0.95 }} - > - <IconButton - onClick={handleHeaderMenuOpen} - aria-label="打开聊天菜单" - aria-controls={isHeaderMenuOpen ? "global-chatbox-header-menu" : undefined} - aria-expanded={isHeaderMenuOpen ? "true" : undefined} - aria-haspopup="menu" - sx={{ - p: 0, - borderRadius: "50%", - }} - > - <Box sx={{ position: "relative" }}> - <Avatar - sx={{ - background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`, - boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`, - width: 48, - height: 48, - }} - > - <AutoAwesome fontSize="medium" sx={{ color: "#fff" }} /> - </Avatar> - <Box - sx={{ - position: "absolute", - bottom: 2, - right: 2, - width: 12, - height: 12, - bgcolor: "success.main", - borderRadius: "50%", - border: "2px solid #fff", - boxShadow: "0 0 0 2px rgba(255,255,255,0.5)" - }} - /> - </Box> - </IconButton> - </motion.div> - - <Box> - <Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}> - Agent - </Typography> - <Typography variant="caption" color="text.secondary" fontWeight={500}> - 你的 AI 助手 - </Typography> - </Box> - </Stack> + <AgentHeader + isStreaming={isStreaming} + menuAnchorEl={headerMenuAnchorEl} + onMenuOpen={handleHeaderMenuOpen} + onMenuClose={handleHeaderMenuClose} + onNewConversation={handleNewConversation} + onClose={onClose} + /> - <Menu - id="global-chatbox-header-menu" - anchorEl={headerMenuAnchorEl} - open={isHeaderMenuOpen} - onClose={handleHeaderMenuClose} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - transformOrigin={{ vertical: "top", horizontal: "left" }} - slotProps={{ - paper: { - elevation: 8, - sx: { - mt: 1, - minWidth: 180, - borderRadius: 3, - border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, - backdropFilter: "blur(12px)", - bgcolor: alpha("#fff", 0.92), - boxShadow: `0 16px 40px -16px ${alpha(theme.palette.common.black, 0.28)}`, - }, - }, - }} - > - <MenuItem onClick={handleNewConversation}> - <ListItemIcon> - <AddCommentRounded fontSize="small" /> - </ListItemIcon> - <ListItemText - primary="新建对话" - secondary="清空当前会话" - primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 600 } }} - secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }} - /> - </MenuItem> - </Menu> - - <motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}> - <IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}> - <CloseRounded /> - </IconButton> - </motion.div> - </Box> + <AgentWorkspace + messages={messages} + isStreaming={isStreaming} + bottomRef={bottomRef} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={handleSpeak} + onPauseSpeech={handlePauseSpeech} + onResumeSpeech={handleResumeSpeech} + onStopSpeech={handleStopSpeech} + isTtsSupported={isTtsSupported} + /> - {/* Messages - Bouncy List */} - <Box - sx={{ - flex: 1, - overflowY: "auto", - px: 2.5, - py: 2, - display: "flex", - flexDirection: "column", - gap: 2.5, - zIndex: 5, - }} - > - <AnimatePresence initial={false}> - {messages.length === 0 && ( - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ type: "spring", stiffness: 200, damping: 20 }} - style={{ margin: "auto", width: "100%" }} - > - <Paper - elevation={0} - sx={{ - p: 4, - borderRadius: 6, - bgcolor: alpha("#fff", 0.6), - border: `1px solid ${alpha(theme.palette.divider, 0.1)}`, - maxWidth: 320, - mx: "auto", - textAlign: "center", - backdropFilter: "blur(10px)", - }} - > - <motion.div - animate={{ y: [-5, 5, -5] }} - transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }} - > - <AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} /> - </motion.div> - <Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom> - 你好呀!👋 - </Typography> - <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}> - 我已准备好为你提供帮助,尽管问我吧! - </Typography> - </Paper> - </motion.div> - )} - - {renderedMessages} - </AnimatePresence> - - {isStreaming && ( - <motion.div - initial={{ opacity: 0, y: 10, scale: 0.9 }} - animate={{ opacity: 1, y: 0, scale: 1 }} - transition={{ type: "spring", stiffness: 300 }} - style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 40 }} - > - <Paper - elevation={0} - sx={{ - p: 1.5, - borderRadius: 4, - bgcolor: alpha("#fff", 0.8), - boxShadow: `0 4px 12px ${alpha("#000", 0.05)}` - }} - > - <TypingIndicator /> - </Paper> - </motion.div> - )} - - <div ref={bottomRef} style={{ height: 1 }} /> - </Box> - - {/* Input Area - Floating Capsule */} - <Box sx={{ p: 3, zIndex: 10 }}> - <Box sx={{ mb: 1.25, display: "flex", justifyContent: "flex-end" }}> - <Box sx={{ position: "relative", width: "100%", maxWidth: 520, display: "flex", justifyContent: "flex-end" }}> - <AnimatePresence initial={false}> - {isPresetPanelOpen && ( - <motion.div - initial={{ opacity: 0, y: 8, scale: 0.98 }} - animate={{ opacity: 1, y: 0, scale: 1 }} - exit={{ opacity: 0, y: 8, scale: 0.98 }} - transition={{ type: "spring", stiffness: 320, damping: 26 }} - style={{ position: "absolute", right: 0, bottom: "calc(100% + 10px)", width: "100%", zIndex: 3 }} - > - <Paper - elevation={12} - sx={{ - p: 1.2, - borderRadius: 3, - bgcolor: alpha("#fff", 0.92), - backdropFilter: "blur(12px)", - border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, - boxShadow: `0 20px 48px -20px ${alpha(theme.palette.common.black, 0.3)}`, - }} - > - <Stack spacing={0.8}> - {PRESET_PROMPTS.map((prompt, index) => ( - <Box - key={`preset-${index}`} - component="button" - type="button" - onClick={() => handlePresetPromptSelect(prompt)} - sx={{ - textAlign: "left", - width: "100%", - px: 1.1, - py: 0.9, - borderRadius: 2, - border: `1px solid ${alpha(theme.palette.divider, 0.24)}`, - bgcolor: alpha("#fff", 0.72), - color: "text.secondary", - fontSize: "0.84rem", - lineHeight: 1.45, - cursor: "pointer", - transition: "all 0.18s ease", - "&:hover": { - borderColor: alpha(theme.palette.primary.main, 0.45), - color: "text.primary", - transform: "translateY(-1px)", - boxShadow: `0 8px 24px -16px ${alpha(theme.palette.primary.main, 0.6)}`, - }, - }} - > - {prompt} - </Box> - ))} - </Stack> - </Paper> - </motion.div> - )} - </AnimatePresence> - - <motion.div whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }}> - <Paper - elevation={10} - sx={{ - borderRadius: 99, - border: `1px solid ${alpha(theme.palette.divider, 0.1)}`, - bgcolor: alpha("#fff", 0.9), - backdropFilter: "blur(10px)", - boxShadow: `0 14px 40px -14px ${alpha(theme.palette.primary.main, 0.35)}`, - overflow: "hidden", - }} - > - <Stack direction="row" alignItems="center" spacing={1} sx={{ pl: 1.2, pr: 0.5, py: 0.5 }}> - <Avatar - sx={{ - width: 28, - height: 28, - background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`, - }} - > - <AutoAwesome sx={{ fontSize: 16, color: "#fff" }} /> - </Avatar> - <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.2 }}> - 常用功能 - </Typography> - <IconButton - size="small" - onClick={() => setIsPresetPanelOpen((prev) => !prev)} - aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"} - sx={{ color: "text.secondary" }} - > - {isPresetPanelOpen ? <KeyboardArrowDownRounded /> : <KeyboardArrowUpRounded />} - </IconButton> - </Stack> - </Paper> - </motion.div> - </Box> - </Box> - - <motion.div - initial={{ y: 20, opacity: 0 }} - animate={{ y: 0, opacity: 1 }} - transition={{ delay: 0.2 }} - > - <Stack - direction="row" - alignItems="center" - component={Paper} - elevation={12} - sx={{ - p: "6px 8px", - borderRadius: 50, // Full capsule - bgcolor: alpha("#fff", 0.9), - backdropFilter: "blur(10px)", - border: `1px solid ${alpha("#fff", 0.6)}`, - boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`, - transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", - "&:hover": { - transform: "translateY(-2px)", - boxShadow: `0 16px 48px -8px ${alpha(theme.palette.primary.main, 0.25)}`, - } - }} - > - <TextField - inputRef={inputRef} - value={input} - onChange={(e) => setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - void handleSend(); - } - }} - placeholder="输入消息给 Agent..." - fullWidth - multiline - maxRows={3} - variant="standard" - InputProps={{ - disableUnderline: true, - sx: { px: 2.5, py: 1.5, fontSize: "1rem" }, - }} - /> - - {isSttSupported && ( - <Box sx={{ display: "flex", alignItems: "center", mr: 1 }}> - {isListening ? ( - <motion.div - animate={{ scale: [1, 1.15, 1] }} - transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} - > - <IconButton - onClick={stopListening} - aria-label="停止语音输入" - sx={{ - color: "error.main", - bgcolor: alpha(theme.palette.error.main, 0.1), - width: 44, - height: 44, - "&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) }, - }} - > - <MicRounded /> - </IconButton> - </motion.div> - ) : ( - <IconButton - onClick={startListening} - disabled={isStreaming} - aria-label="语音输入" - sx={{ - color: "text.secondary", - width: 44, - height: 44, - "&:hover": { color: "primary.main" }, - }} - > - <MicRounded /> - </IconButton> - )} - </Box> - )} - - <Box sx={{ pr: 0.5 }}> - <AnimatePresence mode="wait"> - {isStreaming ? ( - <motion.div - key="stop" - initial={{ scale: 0, rotate: -180 }} - animate={{ scale: 1, rotate: 0 }} - exit={{ scale: 0, rotate: 180 }} - transition={{ type: "spring", stiffness: 400, damping: 25 }} - > - <IconButton - onClick={handleAbort} - sx={{ - bgcolor: alpha(theme.palette.error.main, 0.1), - color: "error.main", - width: 44, height: 44, - "&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) } - }} - > - <StopRounded /> - </IconButton> - </motion.div> - ) : ( - <motion.div - key="send" - initial={{ scale: 0 }} - animate={{ scale: 1 }} - exit={{ scale: 0 }} - transition={{ type: "spring", stiffness: 400, damping: 25 }} - > - <IconButton - disabled={!canSend} - onClick={() => void handleSend()} - sx={{ - bgcolor: canSend ? "primary.main" : "action.disabledBackground", - color: "#fff", - width: 44, height: 44, - transition: "background-color 0.2s", - "&:hover": { - bgcolor: "primary.dark", - boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.5)}` - } - }} - > - <SendRounded sx={{ ml: 0.5 }} /> - </IconButton> - </motion.div> - )} - </AnimatePresence> - </Box> - </Stack> - </motion.div> - </Box> + <AgentComposer + input={input} + inputRef={inputRef} + isStreaming={isStreaming} + isListening={isListening} + isSttSupported={isSttSupported} + presets={PRESET_PROMPTS} + onInputChange={setInput} + onSend={handleSend} + onAbort={abort} + onStartListening={startListening} + onStopListening={stopListening} + onPresetSelect={handlePresetPromptSelect} + /> </Box> </Drawer> ); diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index e8d0a85..745b769 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -6,12 +6,24 @@ export type ChatProgress = { detail?: string; }; +export type AgentArtifactKind = "chart" | "map" | "panel" | "tool"; + +export type AgentArtifact = { + id: string; + tool: string; + kind: AgentArtifactKind; + title: string; + description?: string; + params: Record<string, unknown>; +}; + export type Message = { id: string; role: "user" | "assistant"; content: string; isError?: boolean; progress?: ChatProgress[]; + artifacts?: AgentArtifact[]; }; export type Props = { diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index 94846a2..33879c6 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -3,19 +3,14 @@ import type { PersistedChatState } from "./GlobalChatbox.types"; export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1"; -const THINK_TAG_ALIAS_PATTERN = - /<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi; export const PRESET_PROMPTS = [ "分析当前管网中的水力瓶颈管道,并给出改造建议。", "帮我分析当前管网压力异常点,并按风险等级排序。", "帮我生成一份今日运行简报,包含问题、原因和建议。", + "查询关键 SCADA 点位最近 24 小时的异常波动。", + "排查当前管网爆管风险,并说明优先处置建议。", ]; -export const normalizeThoughtTagToken = (token: string): string => - token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) => - closingSlash ? "</think>" : "<think>", - ); - export const stripMarkdown = (md: string): string => md .replace(/```[\s\S]*?```/g, "") diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts new file mode 100644 index 0000000..e7d2074 --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { streamAgentChat } from "@/lib/chatStream"; +import type { StreamEvent } from "@/lib/chatStream"; +import type { + AgentArtifact, + ChatProgress, + Message, + PersistedChatState, +} from "../GlobalChatbox.types"; +import { CHAT_STORAGE_KEY, createId, getInitialChatState } from "../GlobalChatbox.utils"; + +type UseAgentChatSessionOptions = { + onToolCall: ( + event: StreamEvent & { type: "tool_call" }, + options: { + assistantMessageId: string; + appendArtifact: (messageId: string, artifact: AgentArtifact) => void; + }, + ) => void; + onBeforeSend?: () => void; +}; + +const upsertProgress = ( + progress: ChatProgress[] | undefined, + event: StreamEvent & { type: "progress" }, +) => { + const next = [...(progress ?? [])]; + const index = next.findIndex((item) => item.id === event.id); + const nextItem: ChatProgress = { + id: event.id, + phase: event.phase, + status: event.status, + title: event.title, + detail: event.detail, + }; + if (index >= 0) { + next[index] = nextItem; + } else { + next.push(nextItem); + } + return next; +}; + +const completeRunningProgress = (progress: ChatProgress[] | undefined) => + progress?.map((item) => + item.status === "running" ? { ...item, status: "completed" as const } : item, + ); + +export const useAgentChatSession = ({ + onToolCall, + onBeforeSend, +}: UseAgentChatSessionOptions) => { + const initialChatStateRef = useRef<PersistedChatState | null>(null); + if (initialChatStateRef.current === null) { + initialChatStateRef.current = getInitialChatState(); + } + + const [messages, setMessages] = useState<Message[]>( + initialChatStateRef.current.messages, + ); + const [sessionId, setSessionId] = useState<string | undefined>( + initialChatStateRef.current.sessionId, + ); + const [isStreaming, setIsStreaming] = useState(false); + const abortRef = useRef<AbortController | null>(null); + + useEffect(() => { + const state: PersistedChatState = { messages, sessionId }; + try { + window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.error("[GlobalChatbox] Failed to persist chat state:", error); + } + }, [messages, sessionId]); + + const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { + setMessages((prev) => + prev.map((message) => + message.id === messageId + ? { + ...message, + artifacts: [...(message.artifacts ?? []), artifact], + } + : message, + ), + ); + }, []); + + const sendPrompt = useCallback( + async (rawPrompt: string) => { + const prompt = rawPrompt.trim(); + if (!prompt || isStreaming) return; + onBeforeSend?.(); + + const userId = createId(); + const assistantId = createId(); + setIsStreaming(true); + + setMessages((prev) => [ + ...prev, + { id: userId, role: "user", content: prompt }, + { id: assistantId, role: "assistant", content: "" }, + ]); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + await streamAgentChat({ + message: prompt, + sessionId, + signal: controller.signal, + onEvent: (event) => { + if ("sessionId" in event && !sessionId && event.sessionId) { + setSessionId(event.sessionId); + } + + if (event.type === "token") { + setMessages((prev) => + prev.map((message) => + message.id === assistantId + ? { + ...message, + content: message.content + event.content, + isError: false, + } + : message, + ), + ); + } else if (event.type === "progress") { + setMessages((prev) => + prev.map((message) => + message.id === assistantId + ? { ...message, progress: upsertProgress(message.progress, event) } + : message, + ), + ); + } else if (event.type === "tool_call") { + onToolCall(event, { + assistantMessageId: assistantId, + appendArtifact, + }); + } else if (event.type === "done") { + setMessages((prev) => + prev.map((message) => { + if (message.id !== assistantId) return message; + const completedProgress = completeRunningProgress(message.progress); + if ( + message.content.trim().length === 0 && + !(message.artifacts?.length) + ) { + return { + ...message, + content: + "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", + progress: completedProgress, + }; + } + return { ...message, progress: completedProgress }; + }), + ); + setIsStreaming(false); + } else if (event.type === "error") { + setMessages((prev) => + prev.map((message) => + message.id === assistantId + ? { + ...message, + content: message.content || `⚠️ **错误:** ${event.message}`, + isError: true, + progress: completeRunningProgress(message.progress), + } + : message, + ), + ); + setIsStreaming(false); + } + }, + }); + } catch (error) { + if (abortRef.current?.signal.aborted) { + setMessages((prev) => + prev.filter( + (message) => + !( + message.id === assistantId && + message.role === "assistant" && + message.content.trim().length === 0 && + !(message.artifacts?.length) + ), + ), + ); + return; + } + setMessages((prev) => + prev.map((message) => + message.id === assistantId + ? { + ...message, + content: `⚠️ **错误:** ${String(error)}`, + isError: true, + progress: completeRunningProgress(message.progress), + } + : message, + ), + ); + setIsStreaming(false); + } finally { + abortRef.current = null; + setIsStreaming(false); + } + }, + [appendArtifact, isStreaming, onBeforeSend, onToolCall, sessionId], + ); + + const abort = useCallback(() => { + abortRef.current?.abort(); + setIsStreaming(false); + }, []); + + const reset = useCallback(() => { + abortRef.current?.abort(); + setMessages([]); + setSessionId(undefined); + setIsStreaming(false); + }, []); + + return { + messages, + isStreaming, + sessionId, + sendPrompt, + abort, + reset, + }; +}; diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts new file mode 100644 index 0000000..d66d26f --- /dev/null +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -0,0 +1,237 @@ +"use client"; + +import { useCallback } from "react"; + +import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore"; +import type { StreamEvent } from "@/lib/chatStream"; +import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types"; + +type ToolCallEvent = StreamEvent & { type: "tool_call" }; + +type HandleToolCallOptions = { + assistantMessageId: string; + appendArtifact: (messageId: string, artifact: AgentArtifact) => void; +}; + +const FEATURE_TYPE_MAP: Record< + string, + { layer: string; geometryKind: "point" | "line"; label: string } +> = { + junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" }, + junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" }, + pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" }, + pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" }, + valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" }, + valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" }, + reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" }, + reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" }, + pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" }, + pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" }, + tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, + tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, +}; + +const LOCATE_TOOL_CONFIG: Record< + string, + { layer: string; geometryKind: "point" | "line"; label: string } +> = { + locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" }, + locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" }, + locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" }, + locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" }, + locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" }, + locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, +}; + +const normalizeIds = (params: Record<string, unknown>): string[] => { + const rawIds = params.ids; + if (Array.isArray(rawIds)) { + return rawIds.map((id) => String(id).trim()).filter(Boolean); + } + if (typeof rawIds === "string") { + return rawIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + } + return []; +}; + +const resolveScadaFeatureInfos = (params: Record<string, unknown>): [string, string][] => { + const rawFeatureInfos = params.feature_infos; + if (Array.isArray(rawFeatureInfos)) { + const normalizedFeatureInfos = rawFeatureInfos + .map((item) => (Array.isArray(item) ? item : null)) + .filter((item): item is [unknown, unknown] => Boolean(item)) + .map( + (item) => + [String(item[0] ?? ""), String(item[1] ?? "scada")] as [ + string, + string, + ], + ) + .filter(([id]) => id.trim().length > 0); + if (normalizedFeatureInfos.length > 0) { + return normalizedFeatureInfos; + } + } + + const rawDeviceIds = + params.device_ids ?? + params.deviceId ?? + params.device_id ?? + params.id ?? + params.ids; + const deviceIds = Array.isArray(rawDeviceIds) + ? rawDeviceIds.map((id) => String(id)) + : typeof rawDeviceIds === "string" + ? rawDeviceIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + : []; + + return deviceIds.map((id) => [id, "scada"]); +}; + +const resolveTimeRange = (params: Record<string, unknown>) => ({ + startTime: + (params.start_time as string | undefined) ?? + (params.startTime as string | undefined) ?? + (params.from as string | undefined) ?? + (params.start as string | undefined), + endTime: + (params.end_time as string | undefined) ?? + (params.endTime as string | undefined) ?? + (params.to as string | undefined) ?? + (params.end as string | undefined), +}); + +const compactNames = (names: string[]) => { + if (!names.length) return ""; + return names.length > 3 + ? `${names.slice(0, 3).join(", ")} 等 ${names.length} 个` + : names.join(", "); +}; + +const buildLocateArtifact = ( + tool: string, + params: Record<string, unknown>, +): { artifact: Omit<AgentArtifact, "id" | "params" | "tool">; action: ChatToolAction | null } => { + const ids = normalizeIds(params); + const rawType = params.feature_type; + const featureType = + typeof rawType === "string" ? rawType.trim().toLowerCase() : ""; + const config = tool === "locate_features" + ? FEATURE_TYPE_MAP[featureType] + : LOCATE_TOOL_CONFIG[tool]; + + return { + artifact: { + kind: "map", + title: config ? `地图定位${config.label}` : "地图定位", + description: compactNames(ids), + }, + action: config + ? { + type: "locate_features", + ids, + layer: config.layer, + geometryKind: config.geometryKind, + } + : null, + }; +}; + +const buildToolAction = ( + tool: string, + params: Record<string, unknown>, +): { action: ChatToolAction | null; kind: AgentArtifactKind; title: string; description?: string } => { + if (tool === "show_chart") { + return { + action: null, + kind: "chart", + title: (params.title as string | undefined) ?? "生成图表", + description: "已生成可视化图表", + }; + } + + if (tool === "locate_features" || LOCATE_TOOL_CONFIG[tool]) { + const locate = buildLocateArtifact(tool, params); + return { + action: locate.action, + kind: locate.artifact.kind, + title: locate.artifact.title, + description: locate.artifact.description, + }; + } + + if (tool === "view_history") { + const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? []; + const { startTime, endTime } = resolveTimeRange(params); + return { + action: { + type: "view_history", + featureInfos, + dataType: + (params.data_type as "realtime" | "scheme" | "none" | undefined) ?? + "realtime", + startTime, + endTime, + }, + kind: "panel", + title: "打开计算结果曲线", + description: compactNames(featureInfos.map(([id]) => id)), + }; + } + + if (tool === "view_scada") { + const featureInfos = resolveScadaFeatureInfos(params); + const { startTime, endTime } = resolveTimeRange(params); + return { + action: { + type: "view_scada", + featureInfos, + startTime, + endTime, + }, + kind: "panel", + title: "打开 SCADA 数据面板", + description: compactNames(featureInfos.map(([id]) => id)), + }; + } + + return { + action: null, + kind: "tool", + title: tool || "工具调用", + description: "Agent 已执行工具动作", + }; +}; + +export const useAgentToolActions = () => { + const dispatchToolAction = useChatToolStore((s) => s.dispatch); + + return useCallback( + (event: ToolCallEvent, options: HandleToolCallOptions) => { + const { action, kind, title, description } = buildToolAction( + event.tool, + event.params, + ); + + options.appendArtifact(options.assistantMessageId, { + id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + tool: event.tool, + kind, + title, + description, + params: event.params, + }); + + if (action) { + dispatchToolAction(action); + } + }, + [dispatchToolAction], + ); +}; -- 2.54.0 From 36d1a8d6eaf11a085af6a8dd6b235d6fbcfe7a2c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 13:05:45 +0800 Subject: [PATCH 117/281] =?UTF-8?q?=E9=87=8D=E6=9E=84=20Agent=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=EF=BC=8C=E6=94=AF=E6=8C=81=E5=88=86=E6=94=AF=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E6=B6=88=E6=81=AF=E5=85=8B=E9=9A=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ai-agent.svg | 1 + public/deepseek-logo.svg | 1 + src/components/chat/AgentComposer.tsx | 245 ++++---- src/components/chat/AgentHeader.tsx | 55 +- src/components/chat/AgentProgressTimeline.tsx | 304 ++++++---- src/components/chat/AgentTurn.tsx | 535 +++++++++++++----- src/components/chat/AgentWorkspace.tsx | 219 +++++-- src/components/chat/ChatToolCallBlock.tsx | 263 +++++---- src/components/chat/GlobalChatbox.tsx | 10 + src/components/chat/GlobalChatbox.types.ts | 29 + src/components/chat/GlobalChatbox.utils.ts | 25 +- .../chat/hooks/useAgentChatSession.ts | 367 ++++++++++-- .../chat/hooks/useAgentToolActions.ts | 47 +- src/components/olmap/SCADA/SCADADataPanel.tsx | 27 +- .../olmap/core/Controls/HistoryDataPanel.tsx | 33 +- .../olmap/core/Controls/PropertyPanel.tsx | 30 +- src/hooks/useChatToolActionHandler.ts | 17 +- src/lib/chatStream.test.ts | 47 +- src/lib/chatStream.ts | 49 ++ src/store/chatToolStore.ts | 4 + 20 files changed, 1722 insertions(+), 586 deletions(-) create mode 100644 public/ai-agent.svg create mode 100644 public/deepseek-logo.svg diff --git a/public/ai-agent.svg b/public/ai-agent.svg new file mode 100644 index 0000000..454f10d --- /dev/null +++ b/public/ai-agent.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777523623582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11701" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" fill="#CE75FF" p-id="11702"></path><path d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" fill="#F3E2FF" p-id="11703"></path><path d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#F62E76" p-id="11704"></path><path d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#CD88FF" p-id="11705"></path></svg> \ No newline at end of file diff --git a/public/deepseek-logo.svg b/public/deepseek-logo.svg new file mode 100644 index 0000000..71fd103 --- /dev/null +++ b/public/deepseek-logo.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777457471585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5556" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M550.4 486.4c0-8.533333 4.266667-12.8 12.8-12.8h4.266667c4.266667 0 4.266667 4.266667 4.266666 4.266667s4.266667 4.266667 4.266667 8.533333v4.266667s0 4.266667-4.266667 4.266666c0 0-4.266667 0-4.266666 4.266667h-4.266667-4.266667s-4.266667 0-4.266666-4.266667c0 0 0-4.266667-4.266667-4.266666v-4.266667z" fill="#4D6BFE" p-id="5557"></path><path d="M994.133333 196.266667c-8.533333-4.266667-12.8 4.266667-21.333333 8.533333l-4.266667 4.266667c-12.8 17.066667-34.133333 25.6-55.466666 25.6-34.133333 0-59.733333 8.533333-85.333334 34.133333-4.266667-29.866667-21.333333-51.2-51.2-64-12.8-4.266667-29.866667-12.8-38.4-25.6-8.533333-8.533333-8.533333-21.333333-12.8-29.866667 0-4.266667 0-12.8-8.533333-12.8s-12.8 4.266667-12.8 12.8c-12.8 21.333333-21.333333 46.933333-17.066667 72.533334 0 59.733333 25.6 106.666667 72.533334 136.533333 4.266667 4.266667 8.533333 8.533333 4.266666 12.8-4.266667 12.8-8.533333 21.333333-8.533333 34.133333-4.266667 8.533333-4.266667 8.533333-12.8 4.266667-25.6-12.8-51.2-29.866667-68.266667-46.933333-34.133333-34.133333-64-72.533333-102.4-102.4-8.533333-8.533333-17.066667-12.8-25.6-21.333334-46.933333-34.133333 0-64 8.533334-68.266666 12.8-4.266667 4.266667-17.066667-29.866667-17.066667-34.133333 0-68.266667 12.8-106.666667 29.866667-8.533333 0-12.8 0-21.333333 4.266666-38.4-8.533333-76.8-8.533333-115.2-4.266666-76.8 8.533333-136.533333 42.666667-179.2 106.666666-51.2 76.8-64 157.866667-51.2 247.466667 17.066667 93.866667 64 170.666667 132.266667 230.4 72.533333 64 157.866667 93.866667 256 85.333333 59.733333-4.266667 123.733333-12.8 200.533333-76.8 17.066667 8.533333 38.4 12.8 72.533333 17.066667 25.6 4.266667 51.2 0 68.266667-4.266667 29.866667-4.266667 25.6-34.133333 17.066667-38.4-85.333333-42.666667-68.266667-25.6-85.333334-38.4 42.666667-51.2 110.933333-106.666667 136.533334-285.866666v-34.133334c0-8.533333 4.266667-8.533333 12.8-8.533333 21.333333-4.266667 42.666667-8.533333 59.733333-21.333333 55.466667-29.866667 76.8-81.066667 85.333333-145.066667 0-8.533333 0-17.066667-12.8-21.333333zM507.733333 746.666667c-85.333333-68.266667-123.733333-89.6-140.8-89.6-17.066667 0-12.8 21.333333-8.533333 29.866666 4.266667 12.8 8.533333 21.333333 12.8 29.866667 4.266667 8.533333 8.533333 17.066667-4.266667 25.6-25.6 17.066667-72.533333-4.266667-76.8-8.533333-55.466667-34.133333-98.133333-76.8-132.266666-136.533334-29.866667-51.2-46.933333-110.933333-46.933334-174.933333 0-17.066667 4.266667-21.333333 17.066667-25.6 21.333333-4.266667 42.666667-4.266667 59.733333 0 85.333333 12.8 157.866667 51.2 217.6 115.2 34.133333 34.133333 59.733333 76.8 89.6 119.466667 29.866667 42.666667 59.733333 85.333333 98.133334 119.466666 12.8 12.8 25.6 21.333333 34.133333 25.6-29.866667 0-81.066667 0-119.466667-29.866666z m166.4-196.266667c-8.533333 4.266667-17.066667 4.266667-25.6 4.266667-12.8 0-25.6-4.266667-29.866666-8.533334-12.8-8.533333-17.066667-12.8-21.333334-29.866666v-25.6c4.266667-12.8 0-21.333333-8.533333-29.866667-8.533333-4.266667-17.066667-8.533333-25.6-8.533333-4.266667 0-8.533333 0-8.533333-4.266667 0 0-4.266667 0-4.266667-4.266667v-4.266666-4.266667-4.266667c0-4.266667 8.533333-8.533333 8.533333-8.533333 12.8-8.533333 29.866667-4.266667 46.933334 0 12.8 4.266667 25.6 17.066667 38.4 29.866667 17.066667 17.066667 17.066667 25.6 25.6 38.4 8.533333 12.8 12.8 21.333333 17.066666 34.133333 0 12.8-4.266667 21.333333-12.8 25.6z" fill="#4D6BFE" p-id="5558"></path></svg> diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index af4e7c2..a6484a9 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -1,9 +1,9 @@ "use client"; +import Image from "next/image"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { - Avatar, Box, Chip, Collapse, @@ -15,12 +15,12 @@ import { alpha, useTheme, } from "@mui/material"; -import AutoAwesome from "@mui/icons-material/AutoAwesome"; import SendRounded from "@mui/icons-material/SendRounded"; import StopRounded from "@mui/icons-material/StopRounded"; import MicRounded from "@mui/icons-material/MicRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; +import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; type AgentComposerProps = { input: string; @@ -56,30 +56,41 @@ export const AgentComposer = ({ const [isPresetOpen, setIsPresetOpen] = React.useState(false); return ( - <Box sx={{ px: 3, pb: 3, pt: 1.5, zIndex: 10 }}> + <Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}> <Paper - elevation={0} + elevation={isPresetOpen ? 4 : 0} sx={{ - mb: isPresetOpen ? 1.25 : 0.8, - px: 1.2, - py: 0.85, - borderRadius: 3.5, - bgcolor: alpha("#fff", 0.72), - border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, - backdropFilter: "blur(12px)", + mb: 1.5, + px: 1.5, + py: 1, + borderRadius: 4, + bgcolor: alpha("#fff", 0.6), + border: `1px solid ${alpha("#fff", 0.5)}`, + backdropFilter: "blur(24px)", + boxShadow: isPresetOpen ? `0 -8px 24px ${alpha("#00acc1", 0.1)}` : "none", + transition: "all 0.3s ease", }} - > - <Stack direction="row" spacing={1} alignItems="center"> - <AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} /> - <Typography variant="caption" color="text.secondary" fontWeight={800}> - 常用管网任务 - </Typography> + > + <Stack direction="row" spacing={1} alignItems="center"> + <Image + src="/ai-agent.svg" + alt="TJWater Agent" + width={18} + height={18} + style={{ + objectFit: "contain", + flexShrink: 0, + }} + /> + <Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> + 管网分析快捷指令 + </Typography> <Box sx={{ flex: 1 }} /> <IconButton size="small" onClick={() => setIsPresetOpen((value) => !value)} aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"} - sx={{ width: 26, height: 26, color: "text.secondary" }} + sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }} > {isPresetOpen ? ( <KeyboardArrowDownRounded fontSize="small" /> @@ -89,60 +100,56 @@ export const AgentComposer = ({ </IconButton> </Stack> <Collapse in={isPresetOpen} timeout="auto" unmountOnExit> - <Stack direction="row" spacing={0.8} useFlexGap flexWrap="wrap" sx={{ pt: 0.9 }}> - {presets.map((prompt) => ( - <Chip - key={prompt} - label={prompt.replace(/[。.]$/, "")} - size="small" - clickable - onClick={() => { - onPresetSelect(prompt); - setIsPresetOpen(false); - }} - sx={{ - maxWidth: "100%", - height: 28, - borderRadius: 2, - bgcolor: alpha(theme.palette.primary.main, 0.07), - color: "text.primary", - fontWeight: 600, - "& .MuiChip-label": { - overflow: "hidden", - textOverflow: "ellipsis", - }, - }} - /> - ))} - </Stack> + <Box sx={{ mt: 1.5, mb: 0.5, pb: 1 }}> + <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> + {presets.map((prompt) => ( + <Chip + key={prompt} + label={prompt.replace(/[。.]$/, "")} + size="medium" + clickable + onClick={() => { + onPresetSelect(prompt); + setIsPresetOpen(false); + }} + sx={{ + height: 32, + borderRadius: "16px", + bgcolor: alpha("#fff", 0.7), + border: `1px solid ${alpha("#00acc1", 0.15)}`, + color: "text.primary", + fontWeight: 600, + fontSize: '0.85rem', + boxShadow: `0 2px 6px ${alpha("#000", 0.03)}`, + backdropFilter: "blur(10px)", + "&:hover": { + bgcolor: alpha("#fff", 0.95), + boxShadow: `0 4px 10px ${alpha("#00acc1", 0.2)}`, + borderColor: alpha("#00acc1", 0.4), + color: "#00acc1" + } + }} + /> + ))} + </Box> + </Box> </Collapse> </Paper> <motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}> - <Stack - direction="row" - alignItems="center" - component={Paper} + <Paper elevation={12} sx={{ - p: "6px 8px", + display: "flex", + flexDirection: "column", + p: 1.5, borderRadius: 5, - bgcolor: alpha("#fff", 0.92), - backdropFilter: "blur(10px)", - border: `1px solid ${alpha("#fff", 0.62)}`, - boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`, + bgcolor: alpha("#ffffff", 0.75), + backdropFilter: "blur(40px)", + border: `1px solid ${alpha("#ffffff", 0.9)}`, + boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`, }} > - <Avatar - sx={{ - width: 28, - height: 28, - ml: 0.5, - background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`, - }} - > - <AutoAwesome sx={{ fontSize: 16, color: "#fff" }} /> - </Avatar> <TextField inputRef={inputRef} value={input} @@ -153,62 +160,70 @@ export const AgentComposer = ({ onSend(); } }} - placeholder="描述你的管网分析目标..." + placeholder="描述你的分析目标,或点击上方指令库..." fullWidth multiline - maxRows={4} + maxRows={5} variant="standard" InputProps={{ disableUnderline: true, - sx: { px: 2, py: 1.35, fontSize: "0.98rem" }, + sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" }, }} /> - {isSttSupported ? ( - <Box sx={{ display: "flex", alignItems: "center", mr: 0.5 }}> - {isListening ? ( - <motion.div - animate={{ scale: [1, 1.14, 1] }} - transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} - > - <IconButton - onClick={onStopListening} - aria-label="停止语音输入" - sx={{ - color: "error.main", - bgcolor: alpha(theme.palette.error.main, 0.1), - width: 42, - height: 42, - }} + <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}> + <Stack direction="row" spacing={0.5} alignItems="center"> + <IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}> + <AttachFileRounded fontSize="small" /> + </IconButton> + {isSttSupported ? ( + isListening ? ( + <motion.div + animate={{ scale: [1, 1.14, 1] }} + transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} > - <MicRounded /> + <IconButton + onClick={onStopListening} + aria-label="停止语音输入" + size="small" + sx={{ + color: "error.main", + bgcolor: alpha(theme.palette.error.main, 0.15), + width: 36, + height: 36, + }} + > + <MicRounded fontSize="small" /> + </IconButton> + </motion.div> + ) : ( + <IconButton + onClick={onStartListening} + disabled={isStreaming} + aria-label="语音输入" + size="small" + sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }} + > + <MicRounded fontSize="small" /> </IconButton> - </motion.div> - ) : ( - <IconButton - onClick={onStartListening} - disabled={isStreaming} - aria-label="语音输入" - sx={{ color: "text.secondary", width: 42, height: 42 }} - > - <MicRounded /> - </IconButton> - )} - </Box> - ) : null} + ) + ) : null} + </Stack> - <Box sx={{ pr: 0.5 }}> <AnimatePresence mode="wait"> {isStreaming ? ( <motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> <IconButton onClick={onAbort} aria-label="停止生成" + size="small" sx={{ - bgcolor: alpha(theme.palette.error.main, 0.1), - color: "error.main", - width: 42, - height: 42, + bgcolor: "error.main", + color: "#fff", + width: 40, + height: 40, + boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`, + "&:hover": { bgcolor: "error.dark" }, }} > <StopRounded /> @@ -220,12 +235,14 @@ export const AgentComposer = ({ disabled={!canSend} onClick={onSend} aria-label="发送" + size="small" sx={{ - bgcolor: canSend ? "primary.main" : "action.disabledBackground", - color: "#fff", - width: 42, - height: 42, - "&:hover": { bgcolor: canSend ? "primary.dark" : "action.disabledBackground" }, + bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5), + color: canSend ? "#fff" : "action.disabled", + width: 40, + height: 40, + boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none", + "&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) }, }} > <SendRounded sx={{ ml: 0.35 }} /> @@ -233,9 +250,21 @@ export const AgentComposer = ({ </motion.div> )} </AnimatePresence> - </Box> - </Stack> + </Stack> + </Paper> </motion.div> + <Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, opacity: 0.6 }}> + <Image + src="/deepseek-logo.svg" + alt="DeepSeek" + width={14} + height={14} + style={{ width: 14, height: 14 }} + /> + <Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}> + Powered by DeepSeek V3 · TJWater Agent Intelligence + </Typography> + </Box> </Box> ); }; diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index 472ce3a..b627e72 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import React from "react"; import { motion } from "framer-motion"; import { @@ -15,7 +16,6 @@ import { alpha, useTheme, } from "@mui/material"; -import AutoAwesome from "@mui/icons-material/AutoAwesome"; import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; import CloseRounded from "@mui/icons-material/CloseRounded"; @@ -48,10 +48,14 @@ export const AgentHeader = ({ display: "flex", alignItems: "center", justifyContent: "space-between", + backdropFilter: "blur(20px)", + borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`, + background: `linear-gradient(to bottom, ${alpha("#fff", 0.4)}, ${alpha("#fff", 0.1)})`, + boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`, }} > <Stack direction="row" alignItems="center" spacing={2}> - <motion.div whileHover={{ rotate: 10, scale: 1.08 }} whileTap={{ scale: 0.95 }}> + <motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }}> <IconButton onClick={onMenuOpen} aria-label="打开 Agent 菜单" @@ -63,24 +67,39 @@ export const AgentHeader = ({ <Box sx={{ position: "relative" }}> <Avatar sx={{ - background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`, - boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`, - width: 48, - height: 48, + background: alpha("#ffffff", 0.9), + boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`, + width: 44, + height: 44, + border: `2px solid ${alpha("#fff", 0.8)}`, + p: 0.75, }} > - <AutoAwesome fontSize="medium" sx={{ color: "#fff" }} /> + <Image + src="/ai-agent.svg" + alt="TJWater Agent" + width={30} + height={30} + style={{ width: "100%", height: "100%", objectFit: "contain" }} + /> </Avatar> <Box sx={{ position: "absolute", - bottom: 2, - right: 2, - width: 12, - height: 12, - bgcolor: isStreaming ? "warning.main" : "success.main", + bottom: -2, + right: -2, + width: 14, + height: 14, + bgcolor: isStreaming ? "#ff9800" : "#00e676", borderRadius: "50%", - border: "2px solid #fff", + border: "2.5px solid #fff", + boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`, + animation: isStreaming ? "pulse 1.5s infinite" : "none", + "@keyframes pulse": { + "0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` }, + "70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` }, + "100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` }, + } }} /> </Box> @@ -89,18 +108,18 @@ export const AgentHeader = ({ <Box> <Typography variant="h6" - fontWeight={900} + fontWeight={800} sx={{ - background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, + background: `linear-gradient(90deg, #01579b, #00838f)`, backgroundClip: "text", color: "transparent", - letterSpacing: -0.5, + letterSpacing: -0.3, }} > TJWater Agent </Typography> - <Typography variant="caption" color="text.secondary" fontWeight={600}> - {isStreaming ? "正在分析管网任务" : "管网分析工作台"} + <Typography variant="caption" color="text.secondary" fontWeight={500}> + {isStreaming ? "正在思考分析任务..." : "基于大模型的水力分析引擎"} </Typography> </Box> </Stack> diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index fc92ef9..7cbb327 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -3,8 +3,6 @@ import React, { useMemo, useState } from "react"; import { Box, - Button, - Chip, Collapse, LinearProgress, Stack, @@ -20,6 +18,7 @@ import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded"; import TaskAltRounded from "@mui/icons-material/TaskAltRounded"; import PsychologyRounded from "@mui/icons-material/PsychologyRounded"; import SyncRounded from "@mui/icons-material/SyncRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import type { ChatProgress } from "./GlobalChatbox.types"; @@ -27,12 +26,12 @@ const phaseIcon = (phase: string, status: ChatProgress["status"]) => { const sx = { fontSize: 16 }; if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />; if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />; - if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "primary.main" }} />; + if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "#00acc1" }} />; if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />; if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />; if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />; - if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "primary.main" }} />; - return <AutoAwesome sx={{ ...sx, color: "primary.main" }} />; + if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "#00acc1" }} />; + return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />; }; const formatToolTitle = (item: ChatProgress) => { @@ -45,143 +44,224 @@ const formatToolTitle = (item: ChatProgress) => { return item.title; }; -export const AgentProgressTimeline = ({ progress }: { progress: ChatProgress[] }) => { +export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => { const theme = useTheme(); - const hasComplete = progress.some( + + // 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功) + const isOverallComplete = progress.some( (item) => item.phase === "complete" && item.status === "completed", ); - const hasRunning = - !hasComplete && progress.some((item) => item.status === "running"); - const hasError = progress.some((item) => item.status === "error"); - const [expanded, setExpanded] = useState(hasRunning); + + // 修正状态判断:如果外部标记为中断,或者没有完成标记 + const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running"); + const hasError = isAborted || progress.some((item) => item.status === "error"); + + // 展开状态逻辑:默认折叠,保持界面整洁 + const [expanded, setExpanded] = useState(false); const summary = useMemo(() => { - const completedCount = progress.filter((item) => item.status === "completed").length; - const runningItem = hasComplete - ? undefined - : [...progress].reverse().find((item) => item.status === "running"); - if (runningItem) return runningItem.title; - if (hasError) return "过程存在异常"; - if (hasComplete) return `已完成 ${progress.length} 步`; - return `已完成 ${completedCount || progress.length} 步`; - }, [hasComplete, hasError, progress]); + if (isAborted) return `已中断 (进行到第 ${progress.length} 步)`; + if (isOverallComplete) { + return hasError ? `已完成 (含 ${progress.length} 步探索)` : `已完成 (${progress.length} 步)`; + } + const runningItem = [...progress].reverse().find((item) => item.status === "running"); + if (runningItem) return `${runningItem.title}...`; + if (hasError) return "过程异常,尝试恢复中..."; + return `已执行 ${progress.length} 步`; + }, [isOverallComplete, hasError, progress, isAborted]); + + // 根据整体状态决定顶部卡片的颜色主题 + const statusColor = isOverallComplete + ? "#4caf50" // Success Green + : isAborted || (hasError && !hasRunning) + ? theme.palette.error.main // Error Red + : "#00acc1"; // Primary Cyan + + // 默认折叠:只显示最新的三条 + const visibleCount = 3; + const isCollapsible = progress.length > visibleCount; return ( <Box sx={{ - borderRadius: 3, - bgcolor: alpha(theme.palette.primary.main, 0.045), - border: `1px solid ${alpha(theme.palette.primary.main, 0.14)}`, + borderRadius: 4, + bgcolor: alpha(statusColor, 0.04), + border: `1px solid ${alpha(statusColor, 0.15)}`, + backdropFilter: "blur(12px)", overflow: "hidden", + transition: "all 0.3s ease", + "&:hover": { + bgcolor: alpha(statusColor, 0.06), + borderColor: alpha(statusColor, 0.25), + } }} > <Stack direction="row" - spacing={1} + spacing={1.5} alignItems="center" - sx={{ px: 1.5, py: 1.1 }} + onClick={() => setExpanded(!expanded)} + sx={{ + px: 2, + py: 1.25, + cursor: "pointer", + userSelect: "none" + }} > - <AutoAwesome sx={{ fontSize: 17, color: "primary.main" }} /> - <Typography variant="caption" fontWeight={800} color="text.primary"> - Agent 过程 + {isOverallComplete ? ( + <TaskAltRounded sx={{ fontSize: 18, color: statusColor }} /> + ) : hasRunning ? ( + <AutoAwesome sx={{ fontSize: 18, color: statusColor, animation: "spin 2s linear infinite", "@keyframes spin": { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" } } }} /> + ) : hasError ? ( + <ErrorOutlineRounded sx={{ fontSize: 18, color: statusColor }} /> + ) : ( + <AutoAwesome sx={{ fontSize: 18, color: statusColor }} /> + )} + + <Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}> + Agent 过程: {summary} </Typography> - <Chip - size="small" - label={summary} - color={hasError ? "error" : hasRunning ? "primary" : "success"} - variant="outlined" - sx={{ height: 22, fontSize: "0.68rem", maxWidth: 180 }} + + <KeyboardArrowDownRounded + sx={{ + fontSize: 20, + color: "text.secondary", + transform: expanded ? "rotate(180deg)" : "rotate(0deg)", + transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)" + }} /> - <Box sx={{ flex: 1 }} /> - <Button - size="small" - onClick={() => setExpanded((value) => !value)} - sx={{ minWidth: 0, px: 0.75, fontSize: "0.72rem" }} - > - {expanded ? "收起" : "展开"} - </Button> </Stack> - {hasRunning ? <LinearProgress sx={{ height: 3 }} /> : null} - <Collapse in={expanded} timeout="auto"> - <Stack spacing={1} sx={{ px: 1.5, pb: 1.35 }}> - {progress.map((item, index) => ( - <Stack key={item.id} direction="row" spacing={1} alignItems="stretch"> - <Box - sx={{ - position: "relative", - width: 18, - display: "flex", - justifyContent: "center", - flexShrink: 0, - pt: 0.1, - }} - > - {index < progress.length - 1 ? ( - <Box - aria-hidden - sx={{ - position: "absolute", - top: 18, - bottom: -10, - left: "50%", - width: 2, - transform: "translateX(-50%)", - borderRadius: 99, - bgcolor: alpha( - item.status === "error" - ? theme.palette.error.main - : theme.palette.primary.main, - item.status === "completed" ? 0.22 : 0.36, - ), - }} - /> - ) : null} + + {hasRunning && !expanded ? ( + <LinearProgress + sx={{ + height: 2, + bgcolor: "transparent", + "& .MuiLinearProgress-bar": { bgcolor: statusColor } + }} + /> + ) : null} + + <Collapse in={expanded || hasRunning} timeout="auto" unmountOnExit={false}> + <Box> + {hasRunning ? ( + <LinearProgress + sx={{ + height: 1, + bgcolor: alpha(statusColor, 0.1), + "& .MuiLinearProgress-bar": { bgcolor: statusColor } + }} + /> + ) : ( + <Box sx={{ height: 1, bgcolor: alpha(statusColor, 0.1) }} /> + )} + <Stack spacing={0} sx={{ px: 2, py: 1.5 }}> + {progress.map((item, index) => { + const isLast = index === progress.length - 1; + const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount; + + const itemColor = isAborted && isLast + ? theme.palette.error.main + : item.status === "error" + ? theme.palette.error.main + : item.status === "completed" + ? "#4caf50" + : "#00acc1"; + + const content = ( + <Stack key={item.id} direction="row" spacing={1.5} alignItems="stretch"> <Box sx={{ position: "relative", - zIndex: 1, - width: 18, - height: 18, - borderRadius: "50%", - bgcolor: alpha("#fff", 0.92), + width: 20, display: "flex", - alignItems: "center", justifyContent: "center", + flexShrink: 0, + pt: 0.3, }} > - {phaseIcon( - item.phase, - hasComplete && item.status === "running" - ? "completed" - : item.status, - )} - </Box> - </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Typography variant="caption" color="text.primary" fontWeight={700}> - {item.phase === "tool" ? formatToolTitle(item) : item.title} - </Typography> - {item.detail ? ( - <Typography - variant="caption" - component="pre" - color="text.secondary" + {!isLast ? ( + <Box + aria-hidden + sx={{ + position: "absolute", + top: 22, + bottom: -6, + left: "50%", + width: 2, + transform: "translateX(-50%)", + borderRadius: 2, + bgcolor: alpha(itemColor, item.status === "completed" ? 0.2 : 0.4), + }} + /> + ) : null} + <Box sx={{ - display: "block", - mt: 0.25, - m: 0, - whiteSpace: "pre-wrap", - fontFamily: "inherit", - fontSize: "0.7rem", + position: "relative", + zIndex: 1, + width: 20, + height: 20, + borderRadius: "50%", + bgcolor: alpha(theme.palette.background.paper, 0.9), + boxShadow: `0 0 0 2px ${alpha(itemColor, 0.1)}`, + display: "flex", + alignItems: "center", + justifyContent: "center", }} > - {item.detail} + {phaseIcon( + item.phase, + isAborted && isLast ? "error" : + isOverallComplete && item.status === "running" + ? "completed" + : item.status, + )} + </Box> + </Box> + <Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}> + <Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}> + {item.phase === "tool" ? formatToolTitle(item) : item.title} </Typography> - ) : null} - </Box> - </Stack> - ))} - </Stack> + + {item.detail && ( + <Collapse in={expanded || isLast} timeout="auto"> + <Typography + variant="caption" + component="div" + sx={{ + mt: 0.5, + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha(itemColor, 0.05), + border: `1px solid ${alpha(itemColor, 0.1)}`, + color: "text.secondary", + whiteSpace: "pre-wrap", + fontFamily: "var(--font-mono, monospace)", + fontSize: "0.7rem", + lineHeight: 1.5, + wordBreak: "break-all", + }} + > + {item.detail} + </Typography> + </Collapse> + )} + </Box> + </Stack> + ); + + if (isHiddenWhenCollapsed) { + return ( + <Collapse key={item.id} in={expanded} timeout="auto" unmountOnExit={false}> + {content} + </Collapse> + ); + } + return content; + })} + </Stack> + </Box> </Collapse> </Box> ); diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index ff94738..65cc1d9 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -1,12 +1,14 @@ "use client"; +import Image from "next/image"; import React from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { Avatar, Box, + Button, IconButton, Paper, Stack, @@ -14,35 +16,44 @@ import { alpha, useTheme, } from "@mui/material"; -import AutoAwesome from "@mui/icons-material/AutoAwesome"; -import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; -import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; -import PauseRounded from "@mui/icons-material/PauseRounded"; -import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; -import StopRounded from "@mui/icons-material/StopRounded"; - -import { AgentArtifactPanel } from "./AgentArtifactPanel"; -import { AgentProgressTimeline } from "./AgentProgressTimeline"; -import { ChatInlineChart } from "./ChatInlineChart"; -import type { ChatChartSeries } from "./ChatInlineChart"; -import { ChatToolCallBlock } from "./ChatToolCallBlock"; +import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; +import RefreshRounded from "@mui/icons-material/RefreshRounded"; +import EditRounded from "@mui/icons-material/EditRounded"; +import CloseRounded from "@mui/icons-material/CloseRounded"; +import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded"; +import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded"; import { parseAssistantMessageSections, parseContentWithToolCalls, type ContentSegment, } from "./chatMessageSections"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; -import type { Message, SpeechState } from "./GlobalChatbox.types"; +import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; +import { AgentProgressTimeline } from "./AgentProgressTimeline"; +import { ChatInlineChart } from "./ChatInlineChart"; +import type { ChatChartSeries } from "./ChatInlineChart"; +import { ChatToolCallBlock } from "./ChatToolCallBlock"; +import { AgentArtifactPanel } from "./AgentArtifactPanel"; +import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; +import PauseRounded from "@mui/icons-material/PauseRounded"; +import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; +import StopRounded from "@mui/icons-material/StopRounded"; +import SendRounded from "@mui/icons-material/SendRounded"; type AgentTurnProps = { message: Message; + branchState?: BranchState; messageSpeechState: SpeechState; onSpeak: (messageId: string, text: string) => void; onPause: () => void; onResume: () => void; onStopSpeech: () => void; isTtsSupported: boolean; + onRegenerate: () => void; + onEditResubmit: (messageId: string, newContent: string) => void; + onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; }; const MarkdownBlock = ({ children }: { children: string }) => ( @@ -54,16 +65,25 @@ const MarkdownBlock = ({ children }: { children: string }) => ( export const AgentTurn = React.memo( ({ message, + branchState, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, + onRegenerate, + onEditResubmit, + onCycleBranch, }: AgentTurnProps) => { const theme = useTheme(); const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); + const [isHovered, setIsHovered] = React.useState(false); + const [isEditing, setIsEditing] = React.useState(false); + const [editDraft, setEditDraft] = React.useState(message.content); + const rootMessageId = message.branchRootId ?? message.id; + const parsedAssistantSections = !isUser && !isErrorMessage ? parseAssistantMessageSections(message.content) @@ -73,7 +93,7 @@ export const AgentTurn = React.memo( !isUser && !isErrorMessage ? parseContentWithToolCalls(answerContent).segments : [{ type: "text", content: answerContent }]; - + if (isUser) { return ( <motion.div @@ -81,34 +101,189 @@ export const AgentTurn = React.memo( animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8 }} transition={{ type: "spring", stiffness: 350, damping: 25 }} - style={{ alignSelf: "flex-end", maxWidth: "86%" }} + style={{ alignSelf: "flex-end", maxWidth: "86%", position: "relative" }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - <Paper - elevation={8} - sx={{ - p: 2, - borderRadius: 4, - borderBottomRightRadius: 1.5, - color: "#fff", - background: `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`, - boxShadow: `0 10px 28px -8px ${alpha(theme.palette.primary.main, 0.5)}`, - "--chat-md-text": alpha("#fff", 0.96), - "--chat-md-heading": "#fff", - "--chat-md-link": "#E3F2FD", - "--chat-md-link-hover": "#fff", - "--chat-md-inline-code-bg": "rgba(255,255,255,0.2)", - "--chat-md-inline-code-border": alpha("#fff", 0.16), - "--chat-md-inline-code-text": "#fff", - "--chat-md-pre-bg": "rgba(11, 18, 32, 0.56)", - "--chat-md-pre-border": alpha("#fff", 0.12), - "--chat-md-pre-text": "#F8FAFC", - "--chat-md-quote-border": alpha("#fff", 0.5), - "--chat-md-quote-bg": alpha("#fff", 0.08), - "--chat-md-quote-text": alpha("#fff", 0.9), - }} - > - <MarkdownBlock>{message.content}</MarkdownBlock> - </Paper> + {isEditing ? ( + <Paper + elevation={12} + sx={{ + p: 1.5, + borderRadius: 5, + bgcolor: alpha("#ffffff", 0.75), + backdropFilter: "blur(40px)", + border: `1px solid ${alpha("#ffffff", 0.9)}`, + boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`, + minWidth: { xs: 260, sm: 320, md: 400 }, + maxWidth: "100%", + }} + > + <Box component="textarea" + autoFocus + value={editDraft} + onChange={(e) => setEditDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (editDraft.trim() !== message.content) { + onEditResubmit(message.id, editDraft); + } + setIsEditing(false); + } else if (e.key === "Escape") { + setEditDraft(message.content); + setIsEditing(false); + } + }} + sx={{ + width: "100%", + minHeight: 60, + bgcolor: "transparent", + border: "none", + outline: "none", + resize: "none", + fontFamily: "inherit", + fontSize: "1rem", + color: "text.primary", + lineHeight: 1.6, + }} + /> + <Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}> + <IconButton + size="small" + aria-label="取消" + onClick={() => { setEditDraft(message.content); setIsEditing(false); }} + sx={{ + bgcolor: alpha("#000", 0.05), + color: "text.secondary", + width: 34, height: 34, + "&:hover": { bgcolor: alpha("#000", 0.1) } + }} + > + <CloseRounded fontSize="small" /> + </IconButton> + <IconButton + size="small" + aria-label="发送修改" + disabled={editDraft.trim() === "" || editDraft.trim() === message.content} + onClick={() => { + onEditResubmit(message.id, editDraft); + setIsEditing(false); + }} + sx={{ + bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1), + color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled", + width: 34, height: 34, + boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none", + "&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) } + }} + > + <SendRounded fontSize="small" sx={{ ml: 0.2 }} /> + </IconButton> + </Stack> + </Paper> + ) : ( + <> + <Paper + elevation={4} + sx={{ + p: 2, + borderRadius: 5, + borderBottomRightRadius: 2, + color: "#fff", + background: `linear-gradient(135deg, #0288d1, #00acc1)`, + boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`, + backdropFilter: "blur(10px)", + "--chat-md-text": alpha("#fff", 0.96), + "--chat-md-heading": "#fff", + "--chat-md-link": "#e0f7fa", + "--chat-md-link-hover": "#fff", + "--chat-md-inline-code-bg": "rgba(255,255,255,0.15)", + "--chat-md-inline-code-border": alpha("#fff", 0.1), + "--chat-md-inline-code-text": "#fff", + "--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)", + "--chat-md-pre-border": alpha("#fff", 0.1), + "--chat-md-pre-text": "#F8FAFC", + "--chat-md-quote-border": alpha("#fff", 0.4), + "--chat-md-quote-bg": alpha("#fff", 0.05), + "--chat-md-quote-text": alpha("#fff", 0.8), + }} + > + <MarkdownBlock>{message.content}</MarkdownBlock> + + <AnimatePresence> + {isHovered && !isEditing && ( + <motion.div + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.9 }} + transition={{ duration: 0.15 }} + style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }} + > + <IconButton + size="small" + onClick={() => { setIsEditing(true); setEditDraft(message.content); }} + aria-label="编辑提问" + sx={{ + width: 26, + height: 26, + bgcolor: alpha("#fff", 0.9), + color: "#00acc1", + boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`, + "&:hover": { bgcolor: "#fff", color: "#00838f" } + }} + > + <EditRounded sx={{ fontSize: 14 }} /> + </IconButton> + </motion.div> + )} + </AnimatePresence> + </Paper> + + {branchState && branchState.total > 1 ? ( + <Stack + direction="row" + justifyContent="flex-end" + sx={{ mt: 0.5, mr: 0.5 }} + > + <Paper + elevation={0} + sx={{ + display: "flex", + alignItems: "center", + gap: 0.5, + px: 0.5, + py: 0.25, + borderRadius: 4, + bgcolor: alpha("#000", 0.04), + backdropFilter: "blur(4px)", + border: `1px solid ${alpha("#000", 0.08)}`, + }} + > + <IconButton + size="small" + aria-label="上一分支" + onClick={() => onCycleBranch(rootMessageId, -1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + <ChevronLeftRounded sx={{ fontSize: 16 }} /> + </IconButton> + <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}> + {branchState.activeIndex + 1} / {branchState.total} + </Typography> + <IconButton + size="small" + aria-label="下一分支" + onClick={() => onCycleBranch(rootMessageId, 1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + <ChevronRightRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Paper> + </Stack> + ) : null} + </> + )} </motion.div> ); } @@ -119,24 +294,30 @@ export const AgentTurn = React.memo( animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={{ type: "spring", stiffness: 320, damping: 26 }} - style={{ width: "100%" }} + style={{ width: "100%", position: "relative" }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - <Stack direction="row" spacing={1.25} alignItems="flex-start"> + <Stack direction="row" spacing={1.5} alignItems="flex-start"> <Avatar sx={{ - width: 32, - height: 32, - bgcolor: isErrorMessage - ? alpha(theme.palette.error.main, 0.12) - : alpha(theme.palette.secondary.main, 0.12), + width: 34, + height: 34, + background: alpha("#ffffff", 0.9), + boxShadow: `0 4px 12px ${alpha("#00acc1", 0.25)}`, + border: `1.5px solid ${alpha("#fff", 0.8)}`, + color: "#00acc1", mt: 0.25, + p: 0.5, }} > - {isErrorMessage ? ( - <ErrorOutlineRounded sx={{ fontSize: 17, color: "error.main" }} /> - ) : ( - <AutoAwesome sx={{ fontSize: 17, color: "secondary.main" }} /> - )} + <Image + src="/ai-agent.svg" + alt="TJWater Agent" + width={18} + height={18} + style={{ objectFit: "contain" }} + /> </Avatar> <Paper @@ -144,67 +325,45 @@ export const AgentTurn = React.memo( sx={{ flex: 1, minWidth: 0, - p: 1.5, - borderRadius: 4, - bgcolor: alpha("#fff", 0.84), - border: `1px solid ${alpha( - isErrorMessage ? theme.palette.error.main : theme.palette.divider, - isErrorMessage ? 0.34 : 0.16, - )}`, - boxShadow: `0 14px 40px -24px ${alpha(theme.palette.common.black, 0.32)}`, - "--chat-md-text": isErrorMessage ? theme.palette.error.dark : "#1f2937", - "--chat-md-heading": isErrorMessage ? theme.palette.error.dark : "#111827", - "--chat-md-link": isErrorMessage ? theme.palette.error.main : "#7C3AED", - "--chat-md-link-hover": isErrorMessage ? theme.palette.error.dark : "#6D28D9", - "--chat-md-inline-code-bg": isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#EEF2FF", - "--chat-md-inline-code-border": isErrorMessage - ? alpha(theme.palette.error.main, 0.25) - : "#CBD5E1", - "--chat-md-inline-code-text": isErrorMessage - ? theme.palette.error.dark - : "#334155", - "--chat-md-pre-bg": isErrorMessage - ? alpha(theme.palette.error.main, 0.08) - : "#111827", - "--chat-md-pre-border": isErrorMessage - ? alpha(theme.palette.error.main, 0.3) - : "#64748B", - "--chat-md-pre-text": isErrorMessage ? theme.palette.error.dark : "#E5E7EB", - "--chat-md-quote-border": isErrorMessage - ? alpha(theme.palette.error.main, 0.5) - : "#7C3AED", - "--chat-md-quote-bg": isErrorMessage - ? alpha(theme.palette.error.main, 0.06) - : "#F5F3FF", - "--chat-md-quote-text": isErrorMessage ? theme.palette.error.dark : "#475569", + p: 2, + borderRadius: 5, + bgcolor: alpha("#ffffff", 0.65), + border: `1px solid ${alpha("#fff", 0.8)}`, + boxShadow: `0 10px 30px -10px ${alpha(theme.palette.common.black, 0.08)}`, + backdropFilter: "blur(20px)", + position: "relative", + "--chat-md-text": "text.primary", + "--chat-md-heading": "text.primary", + "--chat-md-link": "#00838f", + "--chat-md-link-hover": "#00acc1", + "--chat-md-inline-code-bg": alpha("#00acc1", 0.08), + "--chat-md-inline-code-border": alpha("#00acc1", 0.15), + "--chat-md-inline-code-text": "#006064", + "--chat-md-pre-bg": "#1e293b", + "--chat-md-pre-border": "#475569", + "--chat-md-pre-text": "#f1f5f9", + "--chat-md-quote-border": "#00acc1", + "--chat-md-quote-bg": alpha("#00acc1", 0.04), + "--chat-md-quote-text": "text.secondary", }} > - <Stack spacing={1.4}> - {message.progress?.length && !isErrorMessage ? ( - <AgentProgressTimeline progress={message.progress} /> + <Stack spacing={1.5}> + {message.progress?.length ? ( + <AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} /> ) : null} <Box sx={{ - p: 1.35, - borderRadius: 3, - bgcolor: isErrorMessage - ? alpha(theme.palette.error.main, 0.055) - : alpha("#fff", 0.72), - border: `1px solid ${alpha( - isErrorMessage ? theme.palette.error.main : theme.palette.divider, - isErrorMessage ? 0.18 : 0.12, - )}`, + p: 1.5, + borderRadius: 4, + bgcolor: alpha("#fff", 0.4), + border: `1px solid ${alpha("#fff", 0.6)}`, }} > - <Stack spacing={1}> - {!isErrorMessage ? ( - <Typography variant="caption" color="text.secondary" fontWeight={800}> - 回答 - </Typography> - ) : null} + <Stack spacing={1.2}> + <Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> + 分析结果 + </Typography> {contentSegments.map((segment, segIdx) => { if (segment.type === "text") { const text = segment.content.trim(); @@ -249,45 +408,139 @@ export const AgentTurn = React.memo( })} </Stack> </Box> - - {message.artifacts?.length ? ( - <AgentArtifactPanel artifacts={message.artifacts} /> - ) : null} </Stack> + + <AnimatePresence> + {isHovered && !isErrorMessage && ( + <motion.div + initial={{ opacity: 0, scale: 0.9, y: 5 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.9, y: 5 }} + transition={{ duration: 0.15 }} + style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }} + > + <Paper + elevation={4} + sx={{ + display: "flex", + gap: 0.5, + p: 0.5, + borderRadius: "16px", + bgcolor: alpha("#fff", 0.8), + backdropFilter: "blur(16px)", + border: `1px solid ${alpha("#fff", 0.9)}`, + boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`, + }} + > + <IconButton + size="small" + aria-label="复制" + onClick={() => { + navigator.clipboard.writeText(message.content); + // Could add a toast here + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + <ContentCopyRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton + size="small" + aria-label="重新生成" + onClick={() => { + onRegenerate(); + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + <RefreshRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Paper> + </motion.div> + )} + </AnimatePresence> + </Paper> </Stack> - {!isErrorMessage && isTtsSupported ? ( - <Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 5.4 }}> - {messageSpeechState === "idle" ? ( - <IconButton - size="small" - onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} - aria-label="朗读消息" - sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} + {(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? ( + <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}> + <Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}> + {!isErrorMessage && isTtsSupported ? ( + <> + {messageSpeechState === "idle" ? ( + <IconButton + size="small" + onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} + aria-label="朗读消息" + sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} + > + <VolumeUpRounded sx={{ fontSize: 16 }} /> + </IconButton> + ) : null} + {messageSpeechState === "playing" ? ( + <> + <IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> + <PauseRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + ) : null} + {messageSpeechState === "paused" ? ( + <> + <IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}> + <PlayArrowRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + ) : null} + </> + ) : null} + </Stack> + + {branchState && branchState.total > 1 ? ( + <Stack + direction="row" + justifyContent="flex-start" + sx={{ mr: 0.5 }} > - <VolumeUpRounded sx={{ fontSize: 16 }} /> - </IconButton> - ) : null} - {messageSpeechState === "playing" ? ( - <> - <IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> - <PauseRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> - ) : null} - {messageSpeechState === "paused" ? ( - <> - <IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}> - <PlayArrowRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> + <Paper + elevation={0} + sx={{ + display: "flex", + alignItems: "center", + gap: 0.5, + px: 0.5, + py: 0.25, + borderRadius: 4, + bgcolor: alpha("#000", 0.04), + backdropFilter: "blur(4px)", + border: `1px solid ${alpha("#000", 0.08)}`, + }} + > + <IconButton + size="small" + aria-label="上一分支" + onClick={() => onCycleBranch(rootMessageId, -1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + <ChevronLeftRounded sx={{ fontSize: 16 }} /> + </IconButton> + <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}> + {branchState.activeIndex + 1} / {branchState.total} + </Typography> + <IconButton + size="small" + aria-label="下一分支" + onClick={() => onCycleBranch(rootMessageId, 1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + <ChevronRightRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Paper> + </Stack> ) : null} </Stack> ) : null} diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 208891f..06eafb9 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -1,19 +1,27 @@ "use client"; +import Image from "next/image"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { Box, Paper, Stack, Typography, alpha, useTheme } from "@mui/material"; -import AutoAwesome from "@mui/icons-material/AutoAwesome"; +import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; +import MapRounded from "@mui/icons-material/MapRounded"; import { AgentTurn } from "./AgentTurn"; import { TypingIndicator } from "./GlobalChatbox.parts"; -import type { Message, SpeechState } from "./GlobalChatbox.types"; +import type { + BranchGroup, + BranchTransition, + Message, + SpeechState, +} from "./GlobalChatbox.types"; type AgentWorkspaceProps = { messages: Message[]; + branchGroups: BranchGroup[]; + branchTransition: BranchTransition | null; isStreaming: boolean; bottomRef: React.RefObject<HTMLDivElement | null>; speakingMessageId: string | null; @@ -23,14 +31,18 @@ type AgentWorkspaceProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; + onRegenerate: () => void; + onEditResubmit: (messageId: string, newContent: string) => void; + onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; }; const EmptyState = () => { const theme = useTheme(); const capabilities = [ - { icon: <WaterDropRounded sx={{ fontSize: 18 }} />, label: "水力瓶颈识别" }, - { icon: <SensorsRounded sx={{ fontSize: 18 }} />, label: "SCADA 异常分析" }, - { icon: <TroubleshootRounded sx={{ fontSize: 18 }} />, label: "改造与调度建议" }, + { icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" }, + { icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" }, + { icon: <TroubleshootRounded sx={{ fontSize: 20, color: "#43a047" }} />, label: "调度与改造建议" }, + { icon: <MapRounded sx={{ fontSize: 20, color: "#8e24aa" }} />, label: "GIS 地图联动" }, ]; return ( @@ -38,62 +50,101 @@ const EmptyState = () => { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 200, damping: 20 }} - style={{ margin: "auto", width: "100%" }} + style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }} > <Paper elevation={0} sx={{ - p: 3, - borderRadius: 5, - bgcolor: alpha("#fff", 0.68), - border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, - maxWidth: 380, - mx: "auto", + p: 4, + borderRadius: 4, + bgcolor: alpha("#ffffff", 0.4), + border: `1px solid ${alpha("#fff", 0.8)}`, + boxShadow: `0 16px 40px ${alpha("#000", 0.05)}`, textAlign: "center", - backdropFilter: "blur(10px)", + backdropFilter: "blur(24px)", + position: "relative", + overflow: "hidden", }} > + <Box sx={{ + position: "absolute", + top: -100, + right: -100, + width: 200, + height: 200, + background: "radial-gradient(circle, rgba(0, 172, 193, 0.15) 0%, rgba(255,255,255,0) 70%)", + }} /> <motion.div - animate={{ y: [-5, 5, -5] }} - transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }} + animate={{ + y: [-6, 4, -6], + scale: [1, 1.04, 1], + rotate: [-3, 3, -3], + }} + transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }} + style={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 88, + height: 88, + marginBottom: 12, + borderRadius: "50%", + background: "radial-gradient(circle, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.45) 58%, rgba(255,255,255,0) 100%)", + boxShadow: "0 10px 28px rgba(0, 131, 143, 0.12)", + }} > - <AutoAwesome - sx={{ - fontSize: 54, - color: "primary.main", - mb: 1.6, - filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))", + <Image + src="/ai-agent.svg" + alt="TJWater Agent" + width={54} + height={54} + style={{ + objectFit: "contain", + filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))", }} /> </motion.div> - <Typography variant="h6" color="text.primary" fontWeight={900} gutterBottom> - 管网分析 Agent 已就绪 + <Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom> + 我已就绪,请描述任务 </Typography> - <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.65, mb: 2 }}> - 可以描述你的分析目标,我会展示规划、数据查询过程、地图动作和最终建议。 + <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}> + 你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。 </Typography> - <Stack direction="row" spacing={0.8} useFlexGap flexWrap="wrap" justifyContent="center"> + + <Grid container spacing={1.5}> {capabilities.map((item) => ( - <Stack - key={item.label} - direction="row" - spacing={0.5} - alignItems="center" - sx={{ - px: 1, - py: 0.55, - borderRadius: 99, - bgcolor: alpha(theme.palette.primary.main, 0.07), - color: "text.secondary", - }} - > - {item.icon} - <Typography variant="caption" fontWeight={700}> - {item.label} - </Typography> - </Stack> + <Grid item xs={6} key={item.label}> + <motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}> + <Stack + direction="row" + spacing={1} + alignItems="center" + justifyContent="center" + sx={{ + px: 1.5, + py: 1.5, + borderRadius: 3, + bgcolor: alpha("#fff", 0.5), + border: `1px solid ${alpha("#fff", 0.6)}`, + boxShadow: `0 4px 12px ${alpha("#000", 0.03)}`, + color: "text.primary", + transition: "all 0.2s", + "&:hover": { + bgcolor: alpha("#fff", 0.8), + borderColor: alpha("#00acc1", 0.4), + boxShadow: `0 6px 16px ${alpha("#00acc1", 0.15)}`, + } + }} + > + {item.icon} + <Typography variant="caption" fontWeight={700}> + {item.label} + </Typography> + </Stack> + </motion.div> + </Grid> ))} - </Stack> + </Grid> </Paper> </motion.div> ); @@ -101,6 +152,8 @@ const EmptyState = () => { export const AgentWorkspace = ({ messages, + branchGroups, + branchTransition, isStreaming, bottomRef, speakingMessageId, @@ -110,6 +163,9 @@ export const AgentWorkspace = ({ onResumeSpeech, onStopSpeech, isTtsSupported, + onRegenerate, + onEditResubmit, + onCycleBranch, }: AgentWorkspaceProps) => { const theme = useTheme(); const latestAssistant = [...messages] @@ -120,6 +176,43 @@ export const AgentWorkspace = ({ (!latestAssistant || (latestAssistant.content.trim().length === 0 && !(latestAssistant.artifacts?.length))); + const stableMessages = branchTransition + ? messages.slice(0, branchTransition.parentCount) + : messages; + const transitionMessages = branchTransition + ? messages.slice(branchTransition.parentCount) + : []; + + const renderTurn = (message: Message) => { + const rootMessageId = message.branchRootId ?? message.id; + const branchGroup = branchGroups.find( + (group) => group.rootMessageId === rootMessageId, + ); + + return ( + <AgentTurn + key={rootMessageId} + message={message} + branchState={ + branchGroup && branchGroup.branches.length > 1 + ? { + activeIndex: branchGroup.activeIndex, + total: branchGroup.branches.length, + } + : undefined + } + messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} + onSpeak={onSpeak} + onPause={onPauseSpeech} + onResume={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={onRegenerate} + onEditResubmit={onEditResubmit} + onCycleBranch={onCycleBranch} + /> + ); + }; return ( <Box @@ -130,26 +223,34 @@ export const AgentWorkspace = ({ py: 2, display: "flex", flexDirection: "column", - gap: 2, zIndex: 5, }} > <AnimatePresence initial={false}> {messages.length === 0 ? <EmptyState /> : null} - {messages.map((message) => ( - <AgentTurn - key={message.id} - message={message} - messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} - onSpeak={onSpeak} - onPause={onPauseSpeech} - onResume={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - /> - ))} </AnimatePresence> + {messages.length > 0 ? ( + <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> + {stableMessages.map(renderTurn)} + + {branchTransition ? ( + <AnimatePresence initial={false} mode="wait"> + <motion.div + key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`} + initial={{ opacity: 0, y: 8 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -8 }} + transition={{ duration: 0.18, ease: "easeOut" }} + style={{ display: "flex", flexDirection: "column", gap: 16 }} + > + {transitionMessages.map(renderTurn)} + </motion.div> + </AnimatePresence> + ) : null} + </Box> + ) : null} + {showTypingIndicator ? ( <motion.div initial={{ opacity: 0, y: 10, scale: 0.94 }} diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index 75ae719..a05a497 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -10,11 +10,15 @@ import { Typography, alpha, useTheme, + Collapse, + IconButton, } from "@mui/material"; import LocationOnRounded from "@mui/icons-material/LocationOnRounded"; import TimelineRounded from "@mui/icons-material/TimelineRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import { useChatToolStore, @@ -45,6 +49,26 @@ const LOCATE_TOOL_TO_LAYER: Record<string, string> = { }; const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]); +const LOCATE_ID_PARAM_KEYS = [ + "ids", + "id", + "feature_ids", + "feature_id", + "node_ids", + "node_id", + "junction_ids", + "junction_id", + "pipe_ids", + "pipe_id", + "valve_ids", + "valve_id", + "reservoir_ids", + "reservoir_id", + "pump_ids", + "pump_id", + "tank_ids", + "tank_id", +] as const; const TOOL_META: Record<string, ToolMeta> = { locate_features: { @@ -111,21 +135,32 @@ const TOOL_META: Record<string, ToolMeta> = { /* ---------- helpers ---------- */ -function getToolDescription(toolCall: ToolCall): string { - const { params } = toolCall; - const normalizeIds = (): string[] => { - const rawIds = params.ids; - if (Array.isArray(rawIds)) { - return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); +function normalizeLocateIds(params: Record<string, unknown>): string[] { + for (const key of LOCATE_ID_PARAM_KEYS) { + const rawValue = params[key]; + if (Array.isArray(rawValue)) { + const normalized = rawValue + .map((id) => String(id).trim()) + .filter(Boolean); + if (normalized.length > 0) { + return normalized; + } } - if (typeof rawIds === "string") { - return rawIds + if (typeof rawValue === "string" || typeof rawValue === "number") { + const normalized = String(rawValue) .split(",") .map((id) => id.trim()) .filter(Boolean); + if (normalized.length > 0) { + return normalized; + } } - return []; - }; + } + return []; +} + +function getToolDescription(toolCall: ToolCall): string { + const { params } = toolCall; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -189,7 +224,7 @@ function getToolDescription(toolCall: ToolCall): string { case "locate_reservoirs": case "locate_pumps": case "locate_tanks": { - const ids = normalizeIds(); + const ids = normalizeLocateIds(params); const idsText = ids.length > 3 ? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个` @@ -233,19 +268,6 @@ function getToolDescription(toolCall: ToolCall): string { function buildAction(toolCall: ToolCall): ChatToolAction | null { const { params } = toolCall; - const normalizeIds = (): string[] => { - const rawIds = params.ids; - if (Array.isArray(rawIds)) { - return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); - } - if (typeof rawIds === "string") { - return rawIds - .split(",") - .map((id) => id.trim()) - .filter(Boolean); - } - return []; - }; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -302,13 +324,13 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { ? featureTypeRaw.trim().toLowerCase() : ""; const config = locateFeatureTypeToConfig(featureType); - if (!config) return null; - return { - type: "locate_features", - ids: normalizeIds(), - layer: config.layer, - geometryKind: config.geometryKind, - }; + if (!config) return null; + return { + type: "locate_features", + ids: normalizeLocateIds(params), + layer: config.layer, + geometryKind: config.geometryKind, + }; } case "locate_junctions": case "locate_pipes": @@ -320,7 +342,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { if (!layer) return null; return { type: "locate_features", - ids: normalizeIds(), + ids: normalizeLocateIds(params), layer, geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point", }; @@ -378,12 +400,13 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({ const theme = useTheme(); const dispatch = useChatToolStore((s) => s.dispatch); const [executed, setExecuted] = useState(false); + const [expanded, setExpanded] = useState(false); const meta: ToolMeta = TOOL_META[toolCall.tool] ?? { label: toolCall.tool, - icon: null, + icon: <TimelineRounded sx={{ fontSize: 18 }} />, actionLabel: "执行", - color: theme.palette.primary.main, + color: "#00acc1", }; const description = getToolDescription(toolCall); @@ -400,97 +423,143 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({ <Paper elevation={0} sx={{ - mt: 1.5, + mt: 1, mb: 1, - p: 1.5, - borderRadius: 3, - border: `1px solid ${alpha(meta.color, 0.25)}`, - bgcolor: alpha(meta.color, 0.04), + overflow: "hidden", + borderRadius: 4, + border: `1px solid ${alpha(meta.color, 0.3)}`, + bgcolor: alpha(meta.color, 0.05), + backdropFilter: "blur(12px)", + transition: "all 0.3s ease", + "&:hover": { + bgcolor: alpha(meta.color, 0.08), + border: `1px solid ${alpha(meta.color, 0.4)}`, + } }} > - <Stack direction="row" alignItems="center" spacing={1.5}> + <Box + onClick={() => setExpanded(!expanded)} + sx={{ + p: 1.5, + display: "flex", + alignItems: "center", + cursor: "pointer", + gap: 1.5, + }} + > {/* Icon */} <Box sx={{ width: 32, height: 32, - borderRadius: 2, - bgcolor: alpha(meta.color, 0.12), + borderRadius: "50%", + bgcolor: alpha(meta.color, 0.15), display: "flex", alignItems: "center", justifyContent: "center", color: meta.color, flexShrink: 0, + boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`, }} > {meta.icon} </Box> - {/* Description */} - <Box sx={{ flex: 1, minWidth: 0 }}> + {/* Title */} + <Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}> <Typography - variant="caption" + variant="body2" sx={{ - fontWeight: 600, + fontWeight: 700, color: "text.primary", - display: "block", }} > {meta.label} </Typography> - {description && ( - <Typography - variant="caption" - sx={{ - color: "text.secondary", - fontSize: "0.75rem", - display: "block", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }} - > - {description} - </Typography> + {!expanded && description && ( + <Typography + variant="caption" + sx={{ + color: "text.secondary", + fontSize: "0.75rem", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 180, + opacity: 0.8, + }} + > + • {description} + </Typography> )} </Box> - {/* Action */} - {executed ? ( - <Chip - icon={<CheckCircleRounded sx={{ fontSize: 16 }} />} - label="已执行" - size="small" - sx={{ - bgcolor: alpha("#4caf50", 0.1), - color: "#4caf50", - fontWeight: 600, - fontSize: "0.75rem", - }} - /> - ) : ( - <Button - size="small" - variant="outlined" - onClick={handleExecute} - sx={{ - borderColor: alpha(meta.color, 0.4), - color: meta.color, - fontWeight: 600, - fontSize: "0.75rem", - borderRadius: 2, - textTransform: "none", - whiteSpace: "nowrap", - "&:hover": { - borderColor: meta.color, - bgcolor: alpha(meta.color, 0.08), - }, - }} - > - {meta.actionLabel} - </Button> - )} - </Stack> + <IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}> + {expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />} + </IconButton> + </Box> + + <Collapse in={expanded} timeout="auto" unmountOnExit> + <Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}> + <Stack direction="column" spacing={1.5}> + {description && ( + <Box sx={{ + p: 1.5, + borderRadius: 3, + bgcolor: alpha("#000", 0.03), + border: `1px solid ${alpha("#000", 0.05)}`, + }}> + <Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}> + 执行参数 + </Typography> + <Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}> + {description} + </Typography> + </Box> + )} + + <Stack direction="row" justifyContent="flex-end"> + {executed ? ( + <Chip + icon={<CheckCircleRounded sx={{ fontSize: 16 }} />} + label="已执行" + size="small" + sx={{ + bgcolor: alpha("#00e676", 0.15), + color: "#00c853", + fontWeight: 700, + fontSize: "0.75rem", + }} + /> + ) : ( + <Button + size="small" + variant="contained" + disableElevation + onClick={(e) => { e.stopPropagation(); handleExecute(); }} + sx={{ + bgcolor: meta.color, + color: "#fff", + fontWeight: 700, + fontSize: "0.8rem", + borderRadius: 2.5, + px: 2, + textTransform: "none", + boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`, + "&:hover": { + bgcolor: meta.color, + filter: "brightness(0.9)", + boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`, + }, + }} + > + {meta.actionLabel} + </Button> + )} + </Stack> + </Stack> + </Box> + </Collapse> </Paper> ); }; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 0e1af3c..375771d 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -47,8 +47,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleToolCall = useAgentToolActions(); const { messages, + branchGroups, + branchTransition, isStreaming, sendPrompt, + regenerate, + editAndResubmit, + cycleBranch, abort, reset, } = useAgentChatSession({ @@ -202,6 +207,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <AgentWorkspace messages={messages} + branchGroups={branchGroups} + branchTransition={branchTransition} isStreaming={isStreaming} bottomRef={bottomRef} speakingMessageId={speakingMessageId} @@ -211,6 +218,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onResumeSpeech={handleResumeSpeech} onStopSpeech={handleStopSpeech} isTtsSupported={isTtsSupported} + onRegenerate={regenerate} + onEditResubmit={editAndResubmit} + onCycleBranch={cycleBranch} /> <AgentComposer diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 745b769..f4e3d5c 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -24,6 +24,34 @@ export type Message = { isError?: boolean; progress?: ChatProgress[]; artifacts?: AgentArtifact[]; + branchRootId?: string; +}; + +export type BranchState = { + activeIndex: number; + total: number; +}; + +export type MessageBranch = { + id: string; + label: string; + sessionId?: string; + messages: Message[]; +}; + +export type BranchGroup = { + id: string; + rootMessageId: string; + parentCount: number; + activeIndex: number; + branches: MessageBranch[]; +}; + +export type BranchTransition = { + rootMessageId: string; + parentCount: number; + activeBranchId: string; + nonce: number; }; export type Props = { @@ -36,4 +64,5 @@ export type SpeechState = "idle" | "playing" | "paused"; export type PersistedChatState = { messages: Message[]; sessionId?: string; + branchGroups?: BranchGroup[]; }; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index 33879c6..44d1f46 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -1,4 +1,4 @@ -import type { PersistedChatState } from "./GlobalChatbox.types"; +import type { BranchGroup, Message, PersistedChatState } from "./GlobalChatbox.types"; export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -42,7 +42,11 @@ export const getInitialChatState = (): PersistedChatState => { window.localStorage.removeItem(CHAT_STORAGE_KEY); return { messages: [], sessionId: undefined }; } - return { messages: parsed.messages, sessionId: parsed.sessionId }; + return { + messages: Array.isArray(parsed.messages) ? parsed.messages : [], + sessionId: parsed.sessionId, + branchGroups: Array.isArray(parsed.branchGroups) ? parsed.branchGroups : [], + }; } catch (error) { console.error( "[GlobalChatbox] Failed to read persisted chat state:", @@ -52,3 +56,20 @@ export const getInitialChatState = (): PersistedChatState => { return { messages: [], sessionId: undefined }; } }; + +export const cloneMessage = (message: Message): Message => ({ + ...message, + progress: message.progress ? [...message.progress] : undefined, + artifacts: message.artifacts ? [...message.artifacts] : undefined, +}); + +export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage); + +export const cloneBranchGroups = (branchGroups: BranchGroup[]) => + branchGroups.map((group) => ({ + ...group, + branches: group.branches.map((branch) => ({ + ...branch, + messages: cloneMessages(branch.messages), + })), + })); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index e7d2074..bd82922 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -2,15 +2,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { streamAgentChat } from "@/lib/chatStream"; +import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; import type { AgentArtifact, + BranchGroup, + BranchTransition, ChatProgress, Message, PersistedChatState, } from "../GlobalChatbox.types"; -import { CHAT_STORAGE_KEY, createId, getInitialChatState } from "../GlobalChatbox.utils"; +import { + CHAT_STORAGE_KEY, + cloneBranchGroups, + cloneMessages, + createId, + getInitialChatState, +} from "../GlobalChatbox.utils"; type UseAgentChatSessionOptions = { onToolCall: ( @@ -23,6 +31,14 @@ type UseAgentChatSessionOptions = { onBeforeSend?: () => void; }; +type PromptRunOptions = { + prompt: string; + sessionIdOverride?: string; + preparedMessages?: Message[]; + userMessage?: Message; + assistantMessage?: Message; +}; + const upsertProgress = ( progress: ChatProgress[] | undefined, event: StreamEvent & { type: "progress" }, @@ -49,6 +65,25 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) => item.status === "running" ? { ...item, status: "completed" as const } : item, ); +const createUserMessage = (content: string, branchRootId?: string): Message => { + const id = createId(); + return { + id, + role: "user", + content, + branchRootId: branchRootId ?? id, + }; +}; + +const createAssistantMessage = (): Message => ({ + id: createId(), + role: "assistant", + content: "", +}); + +const messagesEqual = (left: Message[], right: Message[]) => + JSON.stringify(left) === JSON.stringify(right); + export const useAgentChatSession = ({ onToolCall, onBeforeSend, @@ -64,16 +99,65 @@ export const useAgentChatSession = ({ const [sessionId, setSessionId] = useState<string | undefined>( initialChatStateRef.current.sessionId, ); + const [branchGroups, setBranchGroups] = useState<BranchGroup[]>( + initialChatStateRef.current.branchGroups ?? [], + ); + const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null); const [isStreaming, setIsStreaming] = useState(false); const abortRef = useRef<AbortController | null>(null); + const sessionIdRef = useRef<string | undefined>(initialChatStateRef.current.sessionId); + const cancelPromiseRef = useRef<Promise<void> | null>(null); useEffect(() => { - const state: PersistedChatState = { messages, sessionId }; + sessionIdRef.current = sessionId; + }, [sessionId]); + + useEffect(() => { + const state: PersistedChatState = { messages, sessionId, branchGroups }; try { window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); } catch (error) { console.error("[GlobalChatbox] Failed to persist chat state:", error); } + }, [branchGroups, messages, sessionId]); + + useEffect(() => { + setBranchGroups((prev) => { + let changed = false; + const next = prev.map((group) => { + const rootMessage = messages[group.parentCount]; + if ( + !rootMessage || + rootMessage.role !== "user" || + (rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId + ) { + return group; + } + + const activeBranch = group.branches[group.activeIndex]; + if (!activeBranch) { + return group; + } + + const nextSuffix = cloneMessages(messages.slice(group.parentCount)); + if ( + activeBranch.sessionId === sessionId && + messagesEqual(activeBranch.messages, nextSuffix) + ) { + return group; + } + + changed = true; + const branches = group.branches.map((branch, index) => + index === group.activeIndex + ? { ...branch, sessionId, messages: nextSuffix } + : branch, + ); + return { ...group, branches }; + }); + + return changed ? next : prev; + }); }, [messages, sessionId]); const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { @@ -89,21 +173,33 @@ export const useAgentChatSession = ({ ); }, []); - const sendPrompt = useCallback( - async (rawPrompt: string) => { + const runPrompt = useCallback( + async ({ + prompt: rawPrompt, + sessionIdOverride, + preparedMessages, + userMessage, + assistantMessage, + }: PromptRunOptions) => { const prompt = rawPrompt.trim(); if (!prompt || isStreaming) return; + + await cancelPromiseRef.current?.catch(() => undefined); onBeforeSend?.(); + setBranchTransition(null); + + const nextUserMessage = userMessage ?? createUserMessage(prompt); + const nextAssistantMessage = assistantMessage ?? createAssistantMessage(); + const nextMessages = + preparedMessages ?? + [...messages, nextUserMessage, nextAssistantMessage]; - const userId = createId(); - const assistantId = createId(); setIsStreaming(true); - - setMessages((prev) => [ - ...prev, - { id: userId, role: "user", content: prompt }, - { id: assistantId, role: "assistant", content: "" }, - ]); + setMessages(cloneMessages(nextMessages)); + if (sessionIdOverride !== undefined) { + sessionIdRef.current = sessionIdOverride; + setSessionId(sessionIdOverride); + } const controller = new AbortController(); abortRef.current = controller; @@ -111,17 +207,18 @@ export const useAgentChatSession = ({ try { await streamAgentChat({ message: prompt, - sessionId, + sessionId: sessionIdOverride ?? sessionIdRef.current, signal: controller.signal, onEvent: (event) => { - if ("sessionId" in event && !sessionId && event.sessionId) { + if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) { + sessionIdRef.current = event.sessionId; setSessionId(event.sessionId); } if (event.type === "token") { setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, content: message.content + event.content, @@ -133,20 +230,20 @@ export const useAgentChatSession = ({ } else if (event.type === "progress") { setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, progress: upsertProgress(message.progress, event) } : message, ), ); } else if (event.type === "tool_call") { onToolCall(event, { - assistantMessageId: assistantId, + assistantMessageId: nextAssistantMessage.id, appendArtifact, }); } else if (event.type === "done") { setMessages((prev) => prev.map((message) => { - if (message.id !== assistantId) return message; + if (message.id !== nextAssistantMessage.id) return message; const completedProgress = completeRunningProgress(message.progress); if ( message.content.trim().length === 0 && @@ -166,7 +263,7 @@ export const useAgentChatSession = ({ } else if (event.type === "error") { setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, content: message.content || `⚠️ **错误:** ${event.message}`, @@ -181,23 +278,34 @@ export const useAgentChatSession = ({ }, }); } catch (error) { - if (abortRef.current?.signal.aborted) { + if (controller.signal.aborted) { setMessages((prev) => - prev.filter( - (message) => - !( - message.id === assistantId && - message.role === "assistant" && - message.content.trim().length === 0 && - !(message.artifacts?.length) - ), - ), + prev + .map((message) => + message.id === nextAssistantMessage.id + ? { + ...message, + content: message.content || "⚠️ **请求已中断**", + isError: true, + } + : message, + ) + .filter( + (message) => + !( + message.id === nextAssistantMessage.id && + message.role === "assistant" && + message.content.trim().length === 0 && + !(message.artifacts?.length) && + !(message.progress?.length) + ), + ), ); return; } setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, content: `⚠️ **错误:** ${String(error)}`, @@ -213,26 +321,217 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, isStreaming, onBeforeSend, onToolCall, sessionId], + [appendArtifact, isStreaming, messages, onBeforeSend, onToolCall], ); const abort = useCallback(() => { - abortRef.current?.abort(); + const controller = abortRef.current; + controller?.abort(); setIsStreaming(false); + + const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => { + console.error("[GlobalChatbox] Failed to abort agent session:", error); + }); + const trackedCancelPromise = cancelPromise.finally(() => { + if (cancelPromiseRef.current === trackedCancelPromise) { + cancelPromiseRef.current = null; + } + }); + cancelPromiseRef.current = trackedCancelPromise; }, []); const reset = useCallback(() => { - abortRef.current?.abort(); + const controller = abortRef.current; + controller?.abort(); + const activeSessionId = sessionIdRef.current; + if (activeSessionId) { + const cancelPromise = abortAgentChat(activeSessionId).catch((error) => { + console.error("[GlobalChatbox] Failed to abort agent session during reset:", error); + }); + const trackedCancelPromise = cancelPromise.finally(() => { + if (cancelPromiseRef.current === trackedCancelPromise) { + cancelPromiseRef.current = null; + } + }); + cancelPromiseRef.current = trackedCancelPromise; + } setMessages([]); + setBranchGroups([]); + setBranchTransition(null); setSessionId(undefined); + sessionIdRef.current = undefined; setIsStreaming(false); }, []); + const sendPrompt = useCallback( + async (rawPrompt: string) => { + await runPrompt({ prompt: rawPrompt }); + }, + [runPrompt], + ); + + const regenerate = useCallback(async () => { + if (isStreaming || messages.length === 0) return; + + let lastUserIndex = messages.length - 1; + while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") { + lastUserIndex--; + } + + if (lastUserIndex < 0) return; + + const lastUser = messages[lastUserIndex]; + const lastUserContent = lastUser.content; + const nextMessages = cloneMessages(messages.slice(0, lastUserIndex)); + const nextUserMessage = createUserMessage( + lastUserContent, + lastUser.branchRootId ?? lastUser.id, + ); + const nextAssistantMessage = createAssistantMessage(); + + setMessages(nextMessages); + await runPrompt({ + prompt: lastUserContent, + preparedMessages: [ + ...nextMessages, + nextUserMessage, + nextAssistantMessage, + ], + userMessage: nextUserMessage, + assistantMessage: nextAssistantMessage, + }); + }, [isStreaming, messages, runPrompt]); + + const editAndResubmit = useCallback( + async (messageId: string, newContent: string) => { + if (isStreaming) return; + + const trimmedContent = newContent.trim(); + if (!trimmedContent) return; + + const messageIndex = messages.findIndex((m) => m.id === messageId); + if (messageIndex < 0 || messages[messageIndex].role !== "user") return; + + const originalMessage = messages[messageIndex]; + if (trimmedContent === originalMessage.content.trim()) return; + + const rootMessageId = originalMessage.branchRootId ?? originalMessage.id; + const currentSessionId = sessionIdRef.current; + const keepMessageCount = messageIndex; + const prefix = cloneMessages(messages.slice(0, messageIndex)); + const originalSuffix = cloneMessages(messages.slice(messageIndex)); + const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount); + + const nextUserMessage = createUserMessage(trimmedContent, rootMessageId); + const nextAssistantMessage = createAssistantMessage(); + const nextSuffix = [nextUserMessage, nextAssistantMessage]; + + setBranchGroups((prev) => { + const next = cloneBranchGroups(prev); + const groupIndex = next.findIndex( + (group) => + group.rootMessageId === rootMessageId && group.parentCount === messageIndex, + ); + + if (groupIndex >= 0) { + const group = next[groupIndex]; + group.branches[group.activeIndex] = { + ...group.branches[group.activeIndex], + sessionId: currentSessionId, + messages: originalSuffix, + }; + group.branches.push({ + id: createId(), + label: `分支 ${group.branches.length + 1}`, + sessionId: forkedSessionId, + messages: cloneMessages(nextSuffix), + }); + group.activeIndex = group.branches.length - 1; + } else { + next.push({ + id: rootMessageId, + rootMessageId, + parentCount: messageIndex, + activeIndex: 1, + branches: [ + { + id: createId(), + label: "分支 1", + sessionId: currentSessionId, + messages: originalSuffix, + }, + { + id: createId(), + label: "分支 2", + sessionId: forkedSessionId, + messages: cloneMessages(nextSuffix), + }, + ], + }); + } + + return next; + }); + + sessionIdRef.current = forkedSessionId; + setSessionId(forkedSessionId); + await runPrompt({ + prompt: trimmedContent, + sessionIdOverride: forkedSessionId, + preparedMessages: [...prefix, ...nextSuffix], + userMessage: nextUserMessage, + assistantMessage: nextAssistantMessage, + }); + }, + [isStreaming, messages, runPrompt], + ); + + const cycleBranch = useCallback( + (rootMessageId: string, direction: -1 | 1) => { + if (isStreaming) return; + + setBranchGroups((prev) => { + const next = cloneBranchGroups(prev); + const group = next.find((item) => item.rootMessageId === rootMessageId); + if (!group || group.branches.length < 2) { + return prev; + } + + const nextIndex = + (group.activeIndex + direction + group.branches.length) % group.branches.length; + const selectedBranch = group.branches[nextIndex]; + group.activeIndex = nextIndex; + + const nextMessages = [ + ...cloneMessages(messages.slice(0, group.parentCount)), + ...cloneMessages(selectedBranch.messages), + ]; + setBranchTransition({ + rootMessageId, + parentCount: group.parentCount, + activeBranchId: selectedBranch.id, + nonce: Date.now(), + }); + sessionIdRef.current = selectedBranch.sessionId; + setSessionId(selectedBranch.sessionId); + setMessages(nextMessages); + + return next; + }); + }, + [isStreaming, messages], + ); + return { messages, + branchGroups, + branchTransition, isStreaming, sessionId, sendPrompt, + regenerate, + editAndResubmit, + cycleBranch, abort, reset, }; diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts index d66d26f..7d57e62 100644 --- a/src/components/chat/hooks/useAgentToolActions.ts +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -43,16 +43,45 @@ const LOCATE_TOOL_CONFIG: Record< locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, }; +const LOCATE_ID_PARAM_KEYS = [ + "ids", + "id", + "feature_ids", + "feature_id", + "node_ids", + "node_id", + "junction_ids", + "junction_id", + "pipe_ids", + "pipe_id", + "valve_ids", + "valve_id", + "reservoir_ids", + "reservoir_id", + "pump_ids", + "pump_id", + "tank_ids", + "tank_id", +] as const; + const normalizeIds = (params: Record<string, unknown>): string[] => { - const rawIds = params.ids; - if (Array.isArray(rawIds)) { - return rawIds.map((id) => String(id).trim()).filter(Boolean); - } - if (typeof rawIds === "string") { - return rawIds - .split(",") - .map((id) => id.trim()) - .filter(Boolean); + for (const key of LOCATE_ID_PARAM_KEYS) { + const rawValue = params[key]; + if (Array.isArray(rawValue)) { + const normalized = rawValue.map((id) => String(id).trim()).filter(Boolean); + if (normalized.length > 0) { + return normalized; + } + } + if (typeof rawValue === "string" || typeof rawValue === "number") { + const normalized = String(rawValue) + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + if (normalized.length > 0) { + return normalized; + } + } } return []; }; diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index 1477e55..c8b4e40 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -20,6 +20,7 @@ import { ShowChart, TableChart, CleaningServices, + Close, ChevronLeft, ChevronRight, } from "@mui/icons-material"; @@ -72,12 +73,22 @@ export interface SCADADataPanelProps { start_time?: string; /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ end_time?: string; + /** 关闭面板 */ + onClose?: () => void; } type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; +const panelHeaderActionSx = { + color: "primary.contrastText", + backgroundColor: "rgba(255,255,255,0.08)", + "&:hover": { + backgroundColor: "rgba(255,255,255,0.18)", + }, +}; + /** * 从后端 API 获取 SCADA 数据 */ @@ -320,6 +331,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ onCleanData, start_time, end_time, + onClose, }) => { const { open } = useNotification(); const { data: user } = useGetIdentity<IUser>(); @@ -1063,11 +1075,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ /> </Stack> <Stack direction="row" spacing={1}> + {onClose && ( + <Tooltip title="关闭"> + <IconButton + size="small" + onClick={onClose} + aria-label="关闭 SCADA 历史数据面板" + sx={panelHeaderActionSx} + > + <Close fontSize="small" /> + </IconButton> + </Tooltip> + )} <Tooltip title="收起"> <IconButton size="small" onClick={() => setIsExpanded(false)} - sx={{ color: "primary.contrastText" }} + aria-label="收起 SCADA 历史数据面板" + sx={panelHeaderActionSx} > <ChevronRight fontSize="small" /> </IconButton> diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 340f41c..04deb10 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -15,13 +15,14 @@ import { Chip, CircularProgress, Divider, + IconButton, Stack, Tab, Tabs, Tooltip, Typography, } from "@mui/material"; -import { Refresh, ShowChart, TableChart } from "@mui/icons-material"; +import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { zhCN } from "@mui/x-data-grid/locales"; import ReactECharts from "echarts-for-react"; @@ -63,12 +64,22 @@ export interface SCADADataPanelProps { start_time?: string; /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ end_time?: string; + /** 关闭面板 */ + onClose?: () => void; } type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; +const panelHeaderActionSx = { + color: "primary.contrastText", + backgroundColor: "rgba(255,255,255,0.08)", + "&:hover": { + backgroundColor: "rgba(255,255,255,0.18)", + }, +}; + /** * 从后端 API 获取 SCADA 数据 */ @@ -419,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ fractionDigits = 2, start_time, end_time, + onClose, }) => { // 从 featureInfos 中提取设备 ID 列表 const deviceIds = useMemo( @@ -850,7 +862,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ return ( <> {/* 主面板 */} - <Draggable nodeRef={draggableRef} handle=".drag-handle"> + <Draggable + nodeRef={draggableRef} + handle=".drag-handle" + cancel=".panel-close-button" + > <Box ref={draggableRef} sx={{ @@ -915,6 +931,19 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ }} /> </Stack> + {onClose && ( + <Tooltip title="关闭"> + <IconButton + className="panel-close-button" + size="small" + onClick={onClose} + aria-label="关闭历史数据面板" + sx={panelHeaderActionSx} + > + <Close fontSize="small" /> + </IconButton> + </Tooltip> + )} </Stack> </Box> diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index 2cd4d89..2f6343f 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -2,6 +2,8 @@ import React, { useRef } from "react"; import Draggable from "react-draggable"; +import { Close } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; interface BaseProperty { label: string; @@ -24,14 +26,23 @@ interface PropertyPanelProps { id?: string; type?: string; properties?: PropertyItem[]; + onClose?: () => void; } const PropertyPanel: React.FC<PropertyPanelProps> = ({ id, type = "未知类型", properties = [], + onClose, }) => { const draggableRef = useRef<HTMLDivElement>(null); + const headerActionSx = { + color: "common.white", + backgroundColor: "rgba(255,255,255,0.08)", + "&:hover": { + backgroundColor: "rgba(255,255,255,0.18)", + }, + }; const formatValue = (property: BaseProperty) => { if (property.formatter) { @@ -55,7 +66,11 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ : 0; return ( - <Draggable nodeRef={draggableRef} handle=".drag-handle"> + <Draggable + nodeRef={draggableRef} + handle=".drag-handle" + cancel=".panel-close-button" + > <div ref={draggableRef} className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100" @@ -78,6 +93,19 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ </svg> <h3 className="text-lg font-semibold">属性面板</h3> </div> + {onClose && ( + <Tooltip title="关闭"> + <IconButton + className="panel-close-button" + size="small" + onClick={onClose} + aria-label="关闭属性面板" + sx={headerActionSx} + > + <Close fontSize="small" /> + </IconButton> + </Tooltip> + )} </div> {/* 内容区域 */} diff --git a/src/hooks/useChatToolActionHandler.ts b/src/hooks/useChatToolActionHandler.ts index a58e417..19905bc 100644 --- a/src/hooks/useChatToolActionHandler.ts +++ b/src/hooks/useChatToolActionHandler.ts @@ -22,18 +22,33 @@ export function useChatToolActionHandler( handler: (action: ChatToolAction) => void, ) { const handlerRef = useRef(handler); + const lastHandledSeqRef = useRef(0); useEffect(() => { handlerRef.current = handler; }, [handler]); useEffect(() => { + const initialState = useChatToolStore.getState(); + if ( + initialState.lastAction && + initialState.actionSeq > lastHandledSeqRef.current && + Date.now() - initialState.lastActionAt < 5000 + ) { + lastHandledSeqRef.current = initialState.actionSeq; + handlerRef.current(initialState.lastAction); + } else { + lastHandledSeqRef.current = initialState.actionSeq; + } + const unsubscribe = useChatToolStore.subscribe( (state, prevState) => { if ( state.actionSeq !== prevState.actionSeq && - state.lastAction + state.lastAction && + state.actionSeq > lastHandledSeqRef.current ) { + lastHandledSeqRef.current = state.actionSeq; handlerRef.current(state.lastAction); } }, diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 1db2bd5..a2e5103 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -1,4 +1,4 @@ -import { streamAgentChat } from "./chatStream"; +import { abortAgentChat, forkAgentChat, streamAgentChat } from "./chatStream"; import { ReadableStream } from "stream/web"; import { TextEncoder, TextDecoder } from "util"; @@ -147,4 +147,49 @@ describe("streamAgentChat", () => { { type: "error", message: "network request failed", detail: "Failed to fetch" }, ]); }); + + it("calls abort endpoint for an active session", async () => { + apiFetch.mockResolvedValue({ + ok: true, + status: 202, + text: async () => "", + }); + + await abortAgentChat("s1"); + + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/abort"), + expect.objectContaining({ + method: "POST", + projectHeaderMode: "include", + skipAuthRedirect: true, + body: JSON.stringify({ + session_id: "s1", + }), + }), + ); + }); + + it("calls fork endpoint and returns new session id", async () => { + apiFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ session_id: "forked-s1" }), + text: async () => "", + }); + + const sessionId = await forkAgentChat("s1", 3); + + expect(sessionId).toBe("forked-s1"); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/fork"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + session_id: "s1", + keep_message_count: 3, + }), + }), + ); + }); }); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index ad163ad..4d2746a 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -181,3 +181,52 @@ export const streamAgentChat = async ({ } } }; + +export const abortAgentChat = async (sessionId?: string) => { + if (!sessionId) { + return; + } + + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/abort`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: sessionId, + }), + projectHeaderMode: "include", + skipAuthRedirect: true, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `abort request failed: ${response.status}`); + } +}; + +export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => { + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: sessionId, + keep_message_count: keepMessageCount, + }), + projectHeaderMode: "include", + skipAuthRedirect: true, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `fork request failed: ${response.status}`); + } + + const payload = (await response.json()) as { session_id?: string }; + if (!payload.session_id) { + throw new Error("fork request returned no session_id"); + } + return payload.session_id; +}; diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index 3ad963a..c226489 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -41,6 +41,8 @@ interface ChatToolState { lastAction: ChatToolAction | null; /** Monotonically increasing counter – lets subscribers detect new actions. */ actionSeq: number; + /** Timestamp of the most recent action dispatch. */ + lastActionAt: number; /** Dispatch a tool action from the chat. */ dispatch: (action: ChatToolAction) => void; } @@ -48,9 +50,11 @@ interface ChatToolState { export const useChatToolStore = create<ChatToolState>((set) => ({ lastAction: null, actionSeq: 0, + lastActionAt: 0, dispatch: (action) => set((state) => ({ lastAction: action, actionSeq: state.actionSeq + 1, + lastActionAt: Date.now(), })), })); -- 2.54.0 From 85b4f45d4a480bfd5b3d24dfa92cfa0d02b6a25f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 13:38:53 +0800 Subject: [PATCH 118/281] =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=8F=82=E6=95=B0=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/chatStream.test.ts | 29 +++++++++++++++++++++++++++++ src/lib/chatStream.ts | 30 ++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index a2e5103..6cf3f16 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -97,6 +97,35 @@ describe("streamAgentChat", () => { }); }); + it("parses legacy tool_call arguments when params is empty", async () => { + apiFetch.mockResolvedValue({ + ok: true, + body: makeStream([ + 'event: tool_call\ndata: {"conversationId":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n', + 'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n', + ]), + }); + + const events: Array<{ + type: string; + sessionId?: string; + tool?: string; + params?: Record<string, unknown>; + }> = []; + + await streamAgentChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events[0]).toEqual({ + type: "tool_call", + sessionId: "agent-1e75dd01-29e", + tool: "locate_features", + params: { ids: ["142902"], feature_type: "junction" }, + }); + }); + it("emits error when response is not ok", async () => { apiFetch.mockResolvedValue({ ok: false, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 4d2746a..187cd59 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -52,6 +52,30 @@ const parseEventBlock = (block: string): { event?: string; data?: string } => { }; }; +const isObjectRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const resolveToolParams = ( + params: unknown, + argumentsPayload: unknown, +): Record<string, unknown> => { + if (isObjectRecord(params) && Object.keys(params).length > 0) { + return params; + } + if (isObjectRecord(argumentsPayload)) { + return argumentsPayload; + } + if (typeof argumentsPayload === "string") { + try { + const parsed = JSON.parse(argumentsPayload) as unknown; + return isObjectRecord(parsed) ? parsed : {}; + } catch { + return {}; + } + } + return isObjectRecord(params) ? params : {}; +}; + export const streamAgentChat = async ({ message, sessionId, @@ -125,11 +149,13 @@ export const streamAgentChat = async ({ try { const parsed = JSON.parse(data) as { session_id?: string; + conversationId?: string; content?: string; message?: string; detail?: string; tool?: string; params?: Record<string, unknown>; + arguments?: unknown; id?: string; phase?: string; status?: "running" | "completed" | "error"; @@ -166,9 +192,9 @@ export const streamAgentChat = async ({ } else if (event === "tool_call") { onEvent({ type: "tool_call", - sessionId: parsed.session_id ?? "", + sessionId: parsed.session_id ?? parsed.conversationId ?? "", tool: parsed.tool ?? "", - params: parsed.params ?? {}, + params: resolveToolParams(parsed.params, parsed.arguments), }); } } catch { -- 2.54.0 From 24d81e04e08ef9fce8163af8754046e257a5aaf6 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 13:42:04 +0800 Subject: [PATCH 119/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=E9=9D=A2=E6=9D=BF=E5=85=B3=E9=97=AD=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/core/Controls/Toolbar.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 4a5e07d..58b6721 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -877,7 +877,15 @@ const Toolbar: React.FC<ToolbarProps> = ({ /> )} </div> - {showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />} + {showPropertyPanel && ( + <PropertyPanel + {...getFeatureProperties()} + onClose={() => { + deactivateTool("info"); + setActiveTools((prev) => prev.filter((t) => t !== "info")); + }} + /> + )} {showDrawPanel && map && <DrawPanel />} <div style={{ display: showStyleEditor ? "block" : "none" }}> <StyleEditorPanel @@ -892,6 +900,10 @@ const Toolbar: React.FC<ToolbarProps> = ({ visible={showHistoryPanel} start_time={chatPanelTimeRange?.startTime} end_time={chatPanelTimeRange?.endTime} + onClose={() => { + deactivateTool("history"); + setActiveTools((prev) => prev.filter((t) => t !== "history")); + }} /> ) : HistoryPanel ? ( <HistoryPanel @@ -936,6 +948,10 @@ const Toolbar: React.FC<ToolbarProps> = ({ type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} start_time={chatPanelTimeRange?.startTime} end_time={chatPanelTimeRange?.endTime} + onClose={() => { + deactivateTool("history"); + setActiveTools((prev) => prev.filter((t) => t !== "history")); + }} /> ) : ( <HistoryDataPanel @@ -980,6 +996,10 @@ const Toolbar: React.FC<ToolbarProps> = ({ type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} start_time={chatPanelTimeRange?.startTime} end_time={chatPanelTimeRange?.endTime} + onClose={() => { + deactivateTool("history"); + setActiveTools((prev) => prev.filter((t) => t !== "history")); + }} /> ))} -- 2.54.0 From 8f3c2888233e1bbd3ab3a33e1c1680f716a09d21 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 13:46:22 +0800 Subject: [PATCH 120/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E9=80=BB=E8=BE=91=EF=BC=8C=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/core/Controls/HistoryDataPanel.tsx | 25 +++++++++---------- .../olmap/core/Controls/PropertyPanel.tsx | 25 +++++++++---------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 04deb10..bec278a 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -432,6 +432,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ end_time, onClose, }) => { + const handleClose = onClose ?? (() => {}); // 从 featureInfos 中提取设备 ID 列表 const deviceIds = useMemo( () => featureInfos.map(([id]) => id), @@ -931,19 +932,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ }} /> </Stack> - {onClose && ( - <Tooltip title="关闭"> - <IconButton - className="panel-close-button" - size="small" - onClick={onClose} - aria-label="关闭历史数据面板" - sx={panelHeaderActionSx} - > - <Close fontSize="small" /> - </IconButton> - </Tooltip> - )} + <Tooltip title="关闭"> + <IconButton + className="panel-close-button" + size="small" + onClick={handleClose} + aria-label="关闭历史数据面板" + sx={panelHeaderActionSx} + > + <Close fontSize="small" /> + </IconButton> + </Tooltip> </Stack> </Box> diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index 2f6343f..67cff8e 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -36,6 +36,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ onClose, }) => { const draggableRef = useRef<HTMLDivElement>(null); + const handleClose = onClose ?? (() => {}); const headerActionSx = { color: "common.white", backgroundColor: "rgba(255,255,255,0.08)", @@ -93,19 +94,17 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ </svg> <h3 className="text-lg font-semibold">属性面板</h3> </div> - {onClose && ( - <Tooltip title="关闭"> - <IconButton - className="panel-close-button" - size="small" - onClick={onClose} - aria-label="关闭属性面板" - sx={headerActionSx} - > - <Close fontSize="small" /> - </IconButton> - </Tooltip> - )} + <Tooltip title="关闭"> + <IconButton + className="panel-close-button" + size="small" + onClick={handleClose} + aria-label="关闭属性面板" + sx={headerActionSx} + > + <Close fontSize="small" /> + </IconButton> + </Tooltip> </div> {/* 内容区域 */} -- 2.54.0 From c5b0f43a0dfbdbc1b1f0ad94851a3ae0b6a04ac5 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 13:47:45 +0800 Subject: [PATCH 121/281] =?UTF-8?q?=E5=BC=BA=E5=88=B6=E8=A6=81=E6=B1=82=20?= =?UTF-8?q?onClose=20=E5=B1=9E=E6=80=A7=EF=BC=8C=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=85=B3=E9=97=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/core/Controls/HistoryDataPanel.tsx | 5 ++--- src/components/olmap/core/Controls/PropertyPanel.tsx | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index bec278a..4455589 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -65,7 +65,7 @@ export interface SCADADataPanelProps { /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ end_time?: string; /** 关闭面板 */ - onClose?: () => void; + onClose: () => void; } type PanelTab = "chart" | "table"; @@ -432,7 +432,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ end_time, onClose, }) => { - const handleClose = onClose ?? (() => {}); // 从 featureInfos 中提取设备 ID 列表 const deviceIds = useMemo( () => featureInfos.map(([id]) => id), @@ -936,7 +935,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ <IconButton className="panel-close-button" size="small" - onClick={handleClose} + onClick={onClose} aria-label="关闭历史数据面板" sx={panelHeaderActionSx} > diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index 67cff8e..ea49f1b 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -26,7 +26,7 @@ interface PropertyPanelProps { id?: string; type?: string; properties?: PropertyItem[]; - onClose?: () => void; + onClose: () => void; } const PropertyPanel: React.FC<PropertyPanelProps> = ({ @@ -36,7 +36,6 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ onClose, }) => { const draggableRef = useRef<HTMLDivElement>(null); - const handleClose = onClose ?? (() => {}); const headerActionSx = { color: "common.white", backgroundColor: "rgba(255,255,255,0.08)", @@ -98,7 +97,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ <IconButton className="panel-close-button" size="small" - onClick={handleClose} + onClick={onClose} aria-label="关闭属性面板" sx={headerActionSx} > -- 2.54.0 From e0e78cd95aee807d1b6c920c3898cd8d04c3adfb Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 15:02:08 +0800 Subject: [PATCH 122/281] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=92=8C=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 7 + package.json | 1 + src/components/chat/AgentComposer.tsx | 11 +- src/components/chat/AgentHeader.tsx | 227 +++++----- src/components/chat/AgentHistoryPanel.tsx | 408 ++++++++++++++++++ src/components/chat/GlobalChatbox.tsx | 156 +++++-- src/components/chat/GlobalChatbox.types.ts | 33 +- src/components/chat/GlobalChatbox.utils.ts | 31 +- src/components/chat/chatStorage.ts | 346 +++++++++++++++ .../chat/hooks/useAgentChatSession.ts | 241 +++++++++-- src/lib/chatStream.ts | 7 + 11 files changed, 1247 insertions(+), 221 deletions(-) create mode 100644 src/components/chat/AgentHistoryPanel.tsx create mode 100644 src/components/chat/chatStorage.ts diff --git a/package-lock.json b/package-lock.json index ae9fa19..f7b32e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "framer-motion": "^12.38.0", + "idb": "^8.0.3", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", @@ -15843,6 +15844,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 38f8c4b..9080f31 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "framer-motion": "^12.38.0", + "idb": "^8.0.3", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index a6484a9..1f4b558 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -25,6 +25,7 @@ import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; type AgentComposerProps = { input: string; inputRef: React.RefObject<HTMLInputElement | null>; + isHydrating?: boolean; isStreaming: boolean; isListening: boolean; isSttSupported: boolean; @@ -40,6 +41,7 @@ type AgentComposerProps = { export const AgentComposer = ({ input, inputRef, + isHydrating = false, isStreaming, isListening, isSttSupported, @@ -52,7 +54,7 @@ export const AgentComposer = ({ onPresetSelect, }: AgentComposerProps) => { const theme = useTheme(); - const canSend = input.trim().length > 0 && !isStreaming; + const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; const [isPresetOpen, setIsPresetOpen] = React.useState(false); return ( @@ -160,11 +162,12 @@ export const AgentComposer = ({ onSend(); } }} - placeholder="描述你的分析目标,或点击上方指令库..." + placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} fullWidth multiline maxRows={5} variant="standard" + disabled={isHydrating} InputProps={{ disableUnderline: true, sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" }, @@ -199,7 +202,7 @@ export const AgentComposer = ({ ) : ( <IconButton onClick={onStartListening} - disabled={isStreaming} + disabled={isStreaming || isHydrating} aria-label="语音输入" size="small" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }} @@ -262,7 +265,7 @@ export const AgentComposer = ({ style={{ width: 14, height: 14 }} /> <Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}> - Powered by DeepSeek V3 · TJWater Agent Intelligence + Powered by DeepSeek V4 · TJWater Agent Intelligence </Typography> </Box> </Box> diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index b627e72..3885d58 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -7,37 +7,32 @@ import { Avatar, Box, IconButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, Stack, + Tooltip, Typography, alpha, useTheme, } from "@mui/material"; -import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import CloseRounded from "@mui/icons-material/CloseRounded"; +import HistoryRounded from "@mui/icons-material/HistoryRounded"; type AgentHeaderProps = { isStreaming: boolean; - menuAnchorEl: HTMLElement | null; - onMenuOpen: (event: React.MouseEvent<HTMLElement>) => void; - onMenuClose: () => void; + isHistoryOpen: boolean; + onHistoryToggle: () => void; onNewConversation: () => void; onClose: () => void; }; export const AgentHeader = ({ isStreaming, - menuAnchorEl, - onMenuOpen, - onMenuClose, + isHistoryOpen, + onHistoryToggle, onNewConversation, onClose, }: AgentHeaderProps) => { const theme = useTheme(); - const isMenuOpen = Boolean(menuAnchorEl); return ( <Box @@ -55,55 +50,46 @@ export const AgentHeader = ({ }} > <Stack direction="row" alignItems="center" spacing={2}> - <motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }}> - <IconButton - onClick={onMenuOpen} - aria-label="打开 Agent 菜单" - aria-controls={isMenuOpen ? "global-chatbox-header-menu" : undefined} - aria-expanded={isMenuOpen ? "true" : undefined} - aria-haspopup="menu" - sx={{ p: 0, borderRadius: "50%" }} - > - <Box sx={{ position: "relative" }}> - <Avatar - sx={{ - background: alpha("#ffffff", 0.9), - boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`, - width: 44, - height: 44, - border: `2px solid ${alpha("#fff", 0.8)}`, - p: 0.75, - }} - > - <Image - src="/ai-agent.svg" - alt="TJWater Agent" - width={30} - height={30} - style={{ width: "100%", height: "100%", objectFit: "contain" }} - /> - </Avatar> - <Box - sx={{ - position: "absolute", - bottom: -2, - right: -2, - width: 14, - height: 14, - bgcolor: isStreaming ? "#ff9800" : "#00e676", - borderRadius: "50%", - border: "2.5px solid #fff", - boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`, - animation: isStreaming ? "pulse 1.5s infinite" : "none", - "@keyframes pulse": { - "0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` }, - "70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` }, - "100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` }, - } - }} + <motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex" }}> + <Box sx={{ position: "relative" }}> + <Avatar + sx={{ + background: alpha("#ffffff", 0.9), + boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`, + width: 44, + height: 44, + border: `2px solid ${alpha("#fff", 0.8)}`, + p: 0.75, + }} + > + <Image + src="/ai-agent.svg" + alt="TJWater Agent" + width={30} + height={30} + style={{ width: "100%", height: "100%", objectFit: "contain" }} /> - </Box> - </IconButton> + </Avatar> + <Box + sx={{ + position: "absolute", + bottom: -2, + right: -2, + width: 14, + height: 14, + bgcolor: isStreaming ? "#ff9800" : "#00e676", + borderRadius: "50%", + border: "2.5px solid #fff", + boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`, + animation: isStreaming ? "pulse 1.5s infinite" : "none", + "@keyframes pulse": { + "0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` }, + "70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` }, + "100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` }, + } + }} + /> + </Box> </motion.div> <Box> <Typography @@ -124,54 +110,81 @@ export const AgentHeader = ({ </Box> </Stack> - <Menu - id="global-chatbox-header-menu" - anchorEl={menuAnchorEl} - open={isMenuOpen} - onClose={onMenuClose} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - transformOrigin={{ vertical: "top", horizontal: "left" }} - slotProps={{ - paper: { - elevation: 8, - sx: { - mt: 1, - minWidth: 180, - borderRadius: 3, - border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, - backdropFilter: "blur(12px)", - bgcolor: alpha("#fff", 0.92), - }, - }, - }} - > - <MenuItem onClick={onNewConversation}> - <ListItemIcon> - <AddCommentRounded fontSize="small" /> - </ListItemIcon> - <ListItemText - primary="新建对话" - secondary="清空当前会话" - primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 700 } }} - secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }} - /> - </MenuItem> - </Menu> + <Stack direction="row" spacing={1.25} alignItems="center"> + <Tooltip title="新建对话"> + <motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}> + <IconButton + onClick={onNewConversation} + aria-label="新建对话" + sx={{ + width: 36, + height: 36, + color: "text.primary", + bgcolor: alpha("#fff", 0.54), + border: `1px solid ${alpha("#fff", 0.4)}`, + boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`, + "&:hover": { + bgcolor: "#fff", + color: "#00acc1", + borderColor: alpha("#fff", 0.8), + boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`, + }, + }} + > + <EditNoteRounded sx={{ fontSize: 22 }} /> + </IconButton> + </motion.div> + </Tooltip> - <motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }}> - <IconButton - onClick={onClose} - size="small" - aria-label="关闭 Agent" - sx={{ - color: "text.primary", - bgcolor: alpha("#fff", 0.54), - "&:hover": { bgcolor: "#fff" }, - }} - > - <CloseRounded /> - </IconButton> - </motion.div> + <Tooltip title={isHistoryOpen ? "收起历史会话" : "打开历史会话"}> + <motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}> + <IconButton + onClick={onHistoryToggle} + aria-label={isHistoryOpen ? "收起历史会话" : "打开历史会话"} + sx={{ + width: 36, + height: 36, + color: isHistoryOpen ? "#00acc1" : "text.primary", + bgcolor: isHistoryOpen ? alpha("#00acc1", 0.12) : alpha("#fff", 0.54), + border: `1px solid ${isHistoryOpen ? alpha("#00acc1", 0.2) : alpha("#fff", 0.4)}`, + boxShadow: `0 2px 8px ${isHistoryOpen ? alpha("#00acc1", 0.05) : alpha("#000", 0.02)}`, + "&:hover": { + bgcolor: isHistoryOpen ? alpha("#00acc1", 0.16) : "#fff", + borderColor: isHistoryOpen ? alpha("#00acc1", 0.3) : alpha("#fff", 0.8), + boxShadow: `0 4px 12px ${isHistoryOpen ? alpha("#00acc1", 0.1) : alpha("#000", 0.05)}`, + }, + }} + > + <HistoryRounded sx={{ fontSize: 20 }} /> + </IconButton> + </motion.div> + </Tooltip> + + <Tooltip title="关闭 Agent"> + <motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}> + <IconButton + onClick={onClose} + aria-label="关闭 Agent" + sx={{ + width: 36, + height: 36, + color: "text.primary", + bgcolor: alpha("#fff", 0.54), + border: `1px solid ${alpha("#fff", 0.4)}`, + boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`, + "&:hover": { + bgcolor: "#fff", + color: "#e53935", + borderColor: alpha("#fff", 0.8), + boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`, + }, + }} + > + <CloseRounded sx={{ fontSize: 20 }} /> + </IconButton> + </motion.div> + </Tooltip> + </Stack> </Box> ); }; diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx new file mode 100644 index 0000000..e29fdfd --- /dev/null +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -0,0 +1,408 @@ +"use client"; + +import React from "react"; +import { motion } from "framer-motion"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + IconButton, + Paper, + Stack, + TextField, + Tooltip, + Typography, + alpha, +} from "@mui/material"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; +import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded"; +import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded"; +import SearchRounded from "@mui/icons-material/SearchRounded"; +import WarningRounded from "@mui/icons-material/WarningRounded"; +import type { ChatSessionSummary } from "./GlobalChatbox.types"; + +type AgentHistoryPanelProps = { + sessions: ChatSessionSummary[]; + activeSessionId?: string; + isHydrating?: boolean; + onNewSession: () => void; + onSelectSession: (sessionId: string) => void; + onDeleteSession: (sessionId: string) => void; +}; + +const formatRelativeDate = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const isSameDay = date.toDateString() === now.toDateString(); + if (isSameDay) { + return date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + } + + return date.toLocaleDateString("zh-CN", { + month: "numeric", + day: "numeric", + }); +}; + +const getDayStart = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + +const getSessionGroupLabel = (timestamp: number) => { + const now = new Date(); + const todayStart = getDayStart(now); + const yesterdayStart = todayStart - 24 * 60 * 60 * 1000; + const lastWeekStart = todayStart - 7 * 24 * 60 * 60 * 1000; + + if (timestamp >= todayStart) return "今天"; + if (timestamp >= yesterdayStart) return "昨天"; + if (timestamp >= lastWeekStart) return "过去 7 天"; + return "更早"; +}; + +export const AgentHistoryPanel = ({ + sessions, + activeSessionId, + isHydrating = false, + onNewSession, + onSelectSession, + onDeleteSession, +}: AgentHistoryPanelProps) => { + const [keyword, setKeyword] = React.useState(""); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null); + + const filteredSessions = React.useMemo(() => { + const normalizedKeyword = keyword.trim().toLowerCase(); + if (!normalizedKeyword) return sessions; + return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword)); + }, [keyword, sessions]); + + const groupedSessions = React.useMemo(() => { + const groups = new Map<string, ChatSessionSummary[]>(); + + filteredSessions.forEach((session) => { + const label = getSessionGroupLabel(session.updatedAt); + const existing = groups.get(label); + if (existing) { + existing.push(session); + } else { + groups.set(label, [session]); + } + }); + + return Array.from(groups.entries()); + }, [filteredSessions]); + + const pendingDeleteSession = filteredSessions.find( + (session) => session.id === pendingDeleteSessionId, + ); + + return ( + <> + <Paper + elevation={0} + sx={{ + width: 268, + minWidth: 268, + height: "100%", + display: "flex", + flexDirection: "column", + bgcolor: alpha("#ffffff", 0.54), + borderRight: `1px solid ${alpha("#fff", 0.75)}`, + backdropFilter: "blur(28px)", + boxShadow: `inset -1px 0 0 ${alpha("#fff", 0.35)}`, + }} + > + <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}> + <Box> + <Typography variant="subtitle2" fontWeight={800} color="text.primary"> + 历史会话 + </Typography> + <Typography variant="caption" color="text.secondary"> + 本地保存于浏览器 + </Typography> + </Box> + <Tooltip title="新建对话"> + <motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}> + <IconButton + disabled={isHydrating} + onClick={onNewSession} + aria-label="新建对话" + sx={{ + width: 36, + height: 36, + color: "text.primary", + bgcolor: alpha("#fff", 0.65), + border: `1px solid ${alpha("#fff", 0.5)}`, + boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`, + "&:hover": { + bgcolor: "#fff", + color: "#00acc1", + borderColor: alpha("#fff", 0.9), + boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`, + }, + }} + > + <EditNoteRounded sx={{ fontSize: 22 }} /> + </IconButton> + </motion.div> + </Tooltip> + </Stack> + + <Box sx={{ px: 1.5, pb: 1.5 }}> + <TextField + value={keyword} + onChange={(event) => setKeyword(event.target.value)} + placeholder="搜索历史会话" + size="small" + fullWidth + disabled={isHydrating} + InputProps={{ + startAdornment: <SearchRounded sx={{ fontSize: 16, color: "text.secondary", mr: 0.75 }} />, + sx: { + borderRadius: 3, + bgcolor: alpha("#fff", 0.62), + fontSize: "0.85rem", + }, + }} + /> + </Box> + + <Divider sx={{ borderColor: alpha("#fff", 0.6) }} /> + + <Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}> + {sessions.length === 0 ? ( + <Stack + alignItems="center" + justifyContent="center" + spacing={1} + sx={{ + height: "100%", + textAlign: "center", + color: "text.secondary", + px: 2, + }} + > + <ChatBubbleOutlineRounded sx={{ fontSize: 24, opacity: 0.7 }} /> + <Typography variant="body2" fontWeight={700}> + 暂无历史会话 + </Typography> + <Typography variant="caption"> + 新建对话后会自动出现在这里 + </Typography> + </Stack> + ) : filteredSessions.length === 0 ? ( + <Stack + alignItems="center" + justifyContent="center" + spacing={1} + sx={{ + height: "100%", + textAlign: "center", + color: "text.secondary", + px: 2, + }} + > + <SearchRounded sx={{ fontSize: 24, opacity: 0.7 }} /> + <Typography variant="body2" fontWeight={700}> + 未找到匹配会话 + </Typography> + <Typography variant="caption"> + 试试其他关键词 + </Typography> + </Stack> + ) : ( + <Stack spacing={1.5}> + {groupedSessions.map(([groupLabel, groupSessions]) => ( + <Box key={groupLabel}> + <Typography + variant="caption" + color="text.secondary" + fontWeight={800} + sx={{ px: 0.5, mb: 0.75, display: "block", letterSpacing: 0.3 }} + > + {groupLabel} + </Typography> + + <Stack spacing={1}> + {groupSessions.map((session) => { + const isActive = session.id === activeSessionId; + + return ( + <Paper + key={session.id} + elevation={0} + onClick={() => onSelectSession(session.id)} + sx={{ + px: 1.25, + py: 1, + borderRadius: 3, + cursor: isHydrating ? "default" : "pointer", + bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56), + border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`, + boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`, + transition: "all 0.2s ease", + pointerEvents: isHydrating ? "none" : "auto", + "&:hover": { + bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86), + borderColor: alpha("#00acc1", 0.2), + }, + }} + > + <Stack direction="row" spacing={1} alignItems="flex-start"> + <Box sx={{ flex: 1, minWidth: 0 }}> + <Typography + variant="body2" + fontWeight={isActive ? 800 : 700} + color="text.primary" + sx={{ + overflow: "hidden", + textOverflow: "ellipsis", + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + }} + > + {session.title} + </Typography> + <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}> + {formatRelativeDate(session.updatedAt)} + </Typography> + </Box> + + <Tooltip title="删除会话"> + <span> + <IconButton + size="small" + aria-label="删除会话" + onClick={(event) => { + event.stopPropagation(); + setPendingDeleteSessionId(session.id); + setIsDeleteDialogOpen(true); + }} + sx={{ + width: 24, + height: 24, + color: "text.secondary", + "&:hover": { + color: "error.main", + bgcolor: alpha("#ef5350", 0.08), + }, + }} + > + <DeleteOutlineRounded sx={{ fontSize: 16 }} /> + </IconButton> + </span> + </Tooltip> + </Stack> + </Paper> + ); + })} + </Stack> + </Box> + ))} + </Stack> + )} + </Box> + </Paper> + + <Dialog + open={isDeleteDialogOpen} + onClose={() => setIsDeleteDialogOpen(false)} + TransitionProps={{ + onExited: () => setPendingDeleteSessionId(null) + }} + PaperProps={{ + sx: { + borderRadius: 4, + bgcolor: alpha("#fff", 0.85), + backdropFilter: "blur(24px)", + boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`, + border: `1px solid ${alpha("#fff", 0.6)}`, + minWidth: 320, + }, + }} + > + <DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, pb: 1, pt: 3, px: 3 }}> + <Box + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 40, + height: 40, + borderRadius: "50%", + bgcolor: alpha("#ef5350", 0.12), + color: "#ef5350", + }} + > + <WarningRounded sx={{ fontSize: 22 }} /> + </Box> + <Typography variant="h6" fontWeight={800} color="text.primary"> + 删除确认 + </Typography> + </DialogTitle> + <DialogContent sx={{ px: 3, pb: 2 }}> + <DialogContentText color="text.secondary" sx={{ fontSize: "0.95rem" }}> + 确定要删除 + {pendingDeleteSession ? ( + <Typography component="span" fontWeight={700} color="text.primary"> + “{pendingDeleteSession.title}” + </Typography> + ) : ( + "该会话" + )} + 吗? + <br /> + 此操作不可撤销,删除后聊天记录将永久丢失。 + </DialogContentText> + </DialogContent> + <DialogActions sx={{ px: 3, pb: 3, pt: 1 }}> + <Button + onClick={() => setIsDeleteDialogOpen(false)} + sx={{ + color: "text.secondary", + fontWeight: 600, + borderRadius: 2.5, + px: 2.5, + "&:hover": { bgcolor: alpha("#000", 0.04) }, + }} + > + 取消 + </Button> + <Button + variant="contained" + onClick={() => { + if (pendingDeleteSessionId) { + onDeleteSession(pendingDeleteSessionId); + } + setIsDeleteDialogOpen(false); + }} + sx={{ + bgcolor: "#ef5350", + color: "#fff", + fontWeight: 700, + borderRadius: 2.5, + px: 3, + boxShadow: `0 4px 12px ${alpha("#ef5350", 0.3)}`, + "&:hover": { + bgcolor: "#e53935", + boxShadow: `0 6px 16px ${alpha("#ef5350", 0.4)}`, + }, + }} + > + 确认删除 + </Button> + </DialogActions> + </Dialog> + </> + ); +}; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 375771d..b636dff 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -5,6 +5,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material"; import { AgentComposer } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; +import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentWorkspace } from "./AgentWorkspace"; import { Blob } from "./GlobalChatbox.parts"; import type { Props } from "./GlobalChatbox.types"; @@ -17,7 +18,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [input, setInput] = useState(""); const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); - const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); @@ -47,15 +48,20 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleToolCall = useAgentToolActions(); const { messages, + chatSessions, + activeStorageSessionId, branchGroups, branchTransition, + isHydrating, isStreaming, sendPrompt, regenerate, editAndResubmit, cycleBranch, abort, - reset, + createSession, + removeSession, + switchSession, } = useAgentChatSession({ onToolCall: handleToolCall, onBeforeSend: stopListening, @@ -88,24 +94,34 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }, 0); }, []); - const handleHeaderMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>) => { - setHeaderMenuAnchorEl(event.currentTarget); - }, []); - - const handleHeaderMenuClose = useCallback(() => { - setHeaderMenuAnchorEl(null); - }, []); - const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); - reset(); + void createSession(); setInput(""); - handleHeaderMenuClose(); window.setTimeout(() => { inputRef.current?.focus(); }, 0); - }, [handleHeaderMenuClose, handleStopSpeech, reset, stopListening]); + }, [createSession, handleStopSpeech, stopListening]); + + const handleHistoryToggle = useCallback(() => { + setIsHistoryOpen((prev) => !prev); + }, []); + + const handleSelectSession = useCallback( + (storageSessionId: string) => { + setInput(""); + void switchSession(storageSessionId); + }, + [switchSession], + ); + + const handleDeleteSession = useCallback( + (storageSessionId: string) => { + void removeSession(storageSessionId); + }, + [removeSession], + ); const handleMouseDown = useCallback((event: React.MouseEvent) => { event.preventDefault(); @@ -198,45 +214,91 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <AgentHeader isStreaming={isStreaming} - menuAnchorEl={headerMenuAnchorEl} - onMenuOpen={handleHeaderMenuOpen} - onMenuClose={handleHeaderMenuClose} + isHistoryOpen={isHistoryOpen} + onHistoryToggle={handleHistoryToggle} onNewConversation={handleNewConversation} onClose={onClose} /> - <AgentWorkspace - messages={messages} - branchGroups={branchGroups} - branchTransition={branchTransition} - isStreaming={isStreaming} - bottomRef={bottomRef} - speakingMessageId={speakingMessageId} - speechState={speechState} - onSpeak={handleSpeak} - onPauseSpeech={handlePauseSpeech} - onResumeSpeech={handleResumeSpeech} - onStopSpeech={handleStopSpeech} - isTtsSupported={isTtsSupported} - onRegenerate={regenerate} - onEditResubmit={editAndResubmit} - onCycleBranch={cycleBranch} - /> + <Box sx={{ flex: 1, display: "flex", minHeight: 0, position: "relative", overflow: "hidden" }}> + <Box + onClick={() => setIsHistoryOpen(false)} + sx={{ + position: "absolute", + inset: 0, + bgcolor: alpha("#000", 0.05), + backdropFilter: "blur(2px)", + opacity: isHistoryOpen ? 1 : 0, + pointerEvents: isHistoryOpen ? "auto" : "none", + transition: "opacity 0.3s ease", + zIndex: 10, + }} + /> + <Box + sx={{ + position: "absolute", + top: 0, + bottom: 0, + left: 0, + width: 268, + zIndex: 20, + transform: isHistoryOpen ? "translateX(0)" : "translateX(-100%)", + transition: "transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)", + boxShadow: isHistoryOpen ? `4px 0 24px ${alpha("#000", 0.08)}` : "none", + }} + > + <AgentHistoryPanel + sessions={chatSessions} + activeSessionId={activeStorageSessionId} + isHydrating={isHydrating} + onNewSession={() => { + handleNewConversation(); + setIsHistoryOpen(false); + }} + onSelectSession={(id) => { + handleSelectSession(id); + setIsHistoryOpen(false); + }} + onDeleteSession={handleDeleteSession} + /> + </Box> - <AgentComposer - input={input} - inputRef={inputRef} - isStreaming={isStreaming} - isListening={isListening} - isSttSupported={isSttSupported} - presets={PRESET_PROMPTS} - onInputChange={setInput} - onSend={handleSend} - onAbort={abort} - onStartListening={startListening} - onStopListening={stopListening} - onPresetSelect={handlePresetPromptSelect} - /> + <Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}> + <AgentWorkspace + messages={messages} + branchGroups={branchGroups} + branchTransition={branchTransition} + isStreaming={isStreaming} + bottomRef={bottomRef} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={handleSpeak} + onPauseSpeech={handlePauseSpeech} + onResumeSpeech={handleResumeSpeech} + onStopSpeech={handleStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={regenerate} + onEditResubmit={editAndResubmit} + onCycleBranch={cycleBranch} + /> + + <AgentComposer + input={input} + inputRef={inputRef} + isHydrating={isHydrating} + isStreaming={isStreaming} + isListening={isListening} + isSttSupported={isSttSupported} + presets={PRESET_PROMPTS} + onInputChange={setInput} + onSend={handleSend} + onAbort={abort} + onStartListening={startListening} + onStopListening={stopListening} + onPresetSelect={handlePresetPromptSelect} + /> + </Box> + </Box> </Box> </Drawer> ); diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index f4e3d5c..35e75a9 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -61,8 +61,39 @@ export type Props = { export type SpeechState = "idle" | "playing" | "paused"; -export type PersistedChatState = { +export type LegacyPersistedChatState = { messages: Message[]; sessionId?: string; branchGroups?: BranchGroup[]; }; + +export type ChatSessionRecord = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + sessionId?: string; + messages: Message[]; + branchGroups: BranchGroup[]; +}; + +export type ChatSessionSummary = { + id: string; + title: string; + createdAt: number; + updatedAt: number; +}; + +export type ChatStorageMeta = { + key: "chat-meta"; + activeSessionId?: string; + migratedFromLocalStorage?: boolean; +}; + +export type LoadedChatState = { + storageSessionId?: string; + title?: string; + messages: Message[]; + sessionId?: string; + branchGroups: BranchGroup[]; +}; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index 44d1f46..a02769e 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -1,8 +1,7 @@ -import type { BranchGroup, Message, PersistedChatState } from "./GlobalChatbox.types"; +import type { BranchGroup, Message } from "./GlobalChatbox.types"; export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1"; export const PRESET_PROMPTS = [ "分析当前管网中的水力瓶颈管道,并给出改造建议。", "帮我分析当前管网压力异常点,并按风险等级排序。", @@ -29,34 +28,6 @@ export const stripMarkdown = (md: string): string => .replace(/<[^>]+>/g, "") .trim(); -export const getInitialChatState = (): PersistedChatState => { - if (typeof window === "undefined") { - return { messages: [], sessionId: undefined }; - } - try { - const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); - if (!storedRaw) return { messages: [], sessionId: undefined }; - const parsed = JSON.parse(storedRaw) as PersistedChatState; - if (!Array.isArray(parsed.messages)) { - console.error("[GlobalChatbox] Invalid persisted messages format."); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], sessionId: undefined }; - } - return { - messages: Array.isArray(parsed.messages) ? parsed.messages : [], - sessionId: parsed.sessionId, - branchGroups: Array.isArray(parsed.branchGroups) ? parsed.branchGroups : [], - }; - } catch (error) { - console.error( - "[GlobalChatbox] Failed to read persisted chat state:", - error, - ); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], sessionId: undefined }; - } -}; - export const cloneMessage = (message: Message): Message => ({ ...message, progress: message.progress ? [...message.progress] : undefined, diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts new file mode 100644 index 0000000..1a540e5 --- /dev/null +++ b/src/components/chat/chatStorage.ts @@ -0,0 +1,346 @@ +import { openDB, type DBSchema } from "idb"; + +import type { + BranchGroup, + ChatSessionRecord, + ChatSessionSummary, + ChatStorageMeta, + LegacyPersistedChatState, + LoadedChatState, + Message, +} from "./GlobalChatbox.types"; +import { + cloneBranchGroups, + cloneMessages, + createId, +} from "./GlobalChatbox.utils"; + +const CHAT_DB_NAME = "tjwater-agent-chat"; +const CHAT_DB_VERSION = 1; +const SESSION_STORE = "sessions"; +const META_STORE = "meta"; +const META_KEY = "chat-meta" as const; +const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1"; + +type ChatDB = DBSchema & { + sessions: { + key: string; + value: ChatSessionRecord; + indexes: { + "by-updatedAt": number; + }; + }; + meta: { + key: string; + value: ChatStorageMeta; + }; +}; + +const emptyLoadedChatState = (): LoadedChatState => ({ + storageSessionId: undefined, + title: undefined, + messages: [], + sessionId: undefined, + branchGroups: [], +}); + +const sanitizeMessages = (messages: Message[] | undefined) => + Array.isArray(messages) ? cloneMessages(messages) : []; + +const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => + Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; + +const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => { + if (!session) return emptyLoadedChatState(); + return { + storageSessionId: session.id, + title: session.title, + messages: sanitizeMessages(session.messages), + sessionId: session.sessionId, + branchGroups: sanitizeBranchGroups(session.branchGroups), + }; +}; + +const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({ + id: session.id, + title: session.title, + createdAt: session.createdAt, + updatedAt: session.updatedAt, +}); + +const buildSessionTitle = (messages: Message[]) => { + const firstUserMessage = messages.find((message) => message.role === "user"); + if (!firstUserMessage) return "新对话"; + const title = firstUserMessage.content.replace(/\s+/g, " ").trim(); + if (!title) return "新对话"; + return title.length > 24 ? `${title.slice(0, 24)}...` : title; +}; + +const getDb = () => + openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(SESSION_STORE)) { + const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: "id" }); + sessionStore.createIndex("by-updatedAt", "updatedAt"); + } + + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: "key" }); + } + }, + }); + +const readLegacyChatState = (): LegacyPersistedChatState | null => { + if (typeof window === "undefined") return null; + + try { + const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY); + if (!storedRaw) return null; + + const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState; + if (!Array.isArray(parsed.messages)) { + window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); + return null; + } + + return { + messages: sanitizeMessages(parsed.messages), + sessionId: parsed.sessionId, + branchGroups: sanitizeBranchGroups(parsed.branchGroups), + }; + } catch (error) { + console.error("[GlobalChatbox] Failed to read legacy chat state:", error); + window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); + return null; + } +}; + +const clearLegacyChatState = () => { + if (typeof window === "undefined") return; + window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); +}; + +const getMeta = async () => { + const db = await getDb(); + return db.get(META_STORE, META_KEY); +}; + +const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => { + const db = await getDb(); + await db.put(META_STORE, { + key: META_KEY, + ...meta, + }); +}; + +const getLatestSession = async () => { + const db = await getDb(); + const sessions = await db.getAll(SESSION_STORE); + if (sessions.length === 0) return undefined; + return sessions.sort((left, right) => right.updatedAt - left.updatedAt)[0]; +}; + +const migrateLegacyLocalStorage = async () => { + const meta = await getMeta(); + if (meta?.migratedFromLocalStorage) return; + + const legacyState = readLegacyChatState(); + if (!legacyState) { + await setMeta({ + activeSessionId: meta?.activeSessionId, + migratedFromLocalStorage: true, + }); + return; + } + + const hasContent = + legacyState.messages.length > 0 || + (legacyState.branchGroups?.length ?? 0) > 0 || + Boolean(legacyState.sessionId); + + if (!hasContent) { + clearLegacyChatState(); + await setMeta({ + activeSessionId: undefined, + migratedFromLocalStorage: true, + }); + return; + } + + const now = Date.now(); + const sessionRecord: ChatSessionRecord = { + id: createId(), + title: buildSessionTitle(legacyState.messages), + createdAt: now, + updatedAt: now, + sessionId: legacyState.sessionId, + messages: sanitizeMessages(legacyState.messages), + branchGroups: sanitizeBranchGroups(legacyState.branchGroups), + }; + + const db = await getDb(); + await db.put(SESSION_STORE, sessionRecord); + clearLegacyChatState(); + await setMeta({ + activeSessionId: sessionRecord.id, + migratedFromLocalStorage: true, + }); +}; + +export const loadActiveChatState = async (): Promise<LoadedChatState> => { + if (typeof window === "undefined") return emptyLoadedChatState(); + + await migrateLegacyLocalStorage(); + + const meta = await getMeta(); + const db = await getDb(); + + if (meta?.activeSessionId) { + const activeSession = await db.get(SESSION_STORE, meta.activeSessionId); + if (activeSession) { + return toLoadedChatState(activeSession); + } + } + + const latestSession = await getLatestSession(); + if (!latestSession) { + return emptyLoadedChatState(); + } + + await setMeta({ + activeSessionId: latestSession.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return toLoadedChatState(latestSession); +}; + +export const saveActiveChatState = async ( + state: LoadedChatState, +): Promise<string | undefined> => { + if (typeof window === "undefined") return state.storageSessionId; + + const hasContent = + state.messages.length > 0 || + state.branchGroups.length > 0 || + Boolean(state.sessionId); + + const db = await getDb(); + const existingSession = state.storageSessionId + ? await db.get(SESSION_STORE, state.storageSessionId) + : undefined; + const meta = await getMeta(); + + if (!hasContent) { + if (state.storageSessionId) { + await db.delete(SESSION_STORE, state.storageSessionId); + } + await setMeta({ + activeSessionId: undefined, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + return undefined; + } + + const now = Date.now(); + const storageSessionId = state.storageSessionId ?? createId(); + const computedTitle = buildSessionTitle(state.messages); + const preferredTitle = state.title?.trim(); + const finalTitle = preferredTitle || computedTitle; + const nextRecord: ChatSessionRecord = { + id: storageSessionId, + title: finalTitle, + createdAt: existingSession?.createdAt ?? now, + updatedAt: now, + sessionId: state.sessionId, + messages: sanitizeMessages(state.messages), + branchGroups: sanitizeBranchGroups(state.branchGroups), + }; + + await db.put(SESSION_STORE, nextRecord); + await setMeta({ + activeSessionId: storageSessionId, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return storageSessionId; +}; + +export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { + if (typeof window === "undefined") return []; + + await migrateLegacyLocalStorage(); + + const db = await getDb(); + const sessions = await db.getAll(SESSION_STORE); + return sessions + .sort((left, right) => right.updatedAt - left.updatedAt) + .map(toSessionSummary); +}; + +export const createEmptyChatSession = async (): Promise<LoadedChatState> => { + if (typeof window === "undefined") return emptyLoadedChatState(); + + await migrateLegacyLocalStorage(); + + const now = Date.now(); + const session: ChatSessionRecord = { + id: createId(), + title: "新对话", + createdAt: now, + updatedAt: now, + sessionId: undefined, + messages: [], + branchGroups: [], + }; + + const db = await getDb(); + await db.put(SESSION_STORE, session); + const meta = await getMeta(); + await setMeta({ + activeSessionId: session.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return toLoadedChatState(session); +}; + +export const loadChatSessionById = async (sessionId: string): Promise<LoadedChatState> => { + if (typeof window === "undefined") return emptyLoadedChatState(); + + await migrateLegacyLocalStorage(); + + const db = await getDb(); + const session = await db.get(SESSION_STORE, sessionId); + if (!session) { + return emptyLoadedChatState(); + } + + const meta = await getMeta(); + await setMeta({ + activeSessionId: session.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return toLoadedChatState(session); +}; + +export const deleteChatSession = async (sessionId: string): Promise<string | undefined> => { + if (typeof window === "undefined") return undefined; + + const db = await getDb(); + await db.delete(SESSION_STORE, sessionId); + + const remainingSessions = await db.getAll(SESSION_STORE); + const nextActiveSession = remainingSessions.sort( + (left, right) => right.updatedAt - left.updatedAt, + )[0]; + const meta = await getMeta(); + + await setMeta({ + activeSessionId: nextActiveSession?.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return nextActiveSession?.id; +}; diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index bd82922..f3105cd 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -9,16 +9,23 @@ import type { BranchGroup, BranchTransition, ChatProgress, + ChatSessionSummary, + LoadedChatState, Message, - PersistedChatState, } from "../GlobalChatbox.types"; import { - CHAT_STORAGE_KEY, cloneBranchGroups, cloneMessages, createId, - getInitialChatState, } from "../GlobalChatbox.utils"; +import { + createEmptyChatSession, + deleteChatSession, + listChatSessions, + loadActiveChatState, + loadChatSessionById, + saveActiveChatState, +} from "../chatStorage"; type UseAgentChatSessionOptions = { onToolCall: ( @@ -88,24 +95,20 @@ export const useAgentChatSession = ({ onToolCall, onBeforeSend, }: UseAgentChatSessionOptions) => { - const initialChatStateRef = useRef<PersistedChatState | null>(null); - if (initialChatStateRef.current === null) { - initialChatStateRef.current = getInitialChatState(); - } + const storageSessionIdRef = useRef<string | undefined>(undefined); + const hydrationCompletedRef = useRef(false); + const hydrationNonceRef = useRef(0); - const [messages, setMessages] = useState<Message[]>( - initialChatStateRef.current.messages, - ); - const [sessionId, setSessionId] = useState<string | undefined>( - initialChatStateRef.current.sessionId, - ); - const [branchGroups, setBranchGroups] = useState<BranchGroup[]>( - initialChatStateRef.current.branchGroups ?? [], - ); + const [messages, setMessages] = useState<Message[]>([]); + const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined); + const [sessionId, setSessionId] = useState<string | undefined>(undefined); + const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]); + const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null); const [isStreaming, setIsStreaming] = useState(false); + const [isHydrating, setIsHydrating] = useState(true); const abortRef = useRef<AbortController | null>(null); - const sessionIdRef = useRef<string | undefined>(initialChatStateRef.current.sessionId); + const sessionIdRef = useRef<string | undefined>(undefined); const cancelPromiseRef = useRef<Promise<void> | null>(null); useEffect(() => { @@ -113,13 +116,74 @@ export const useAgentChatSession = ({ }, [sessionId]); useEffect(() => { - const state: PersistedChatState = { messages, sessionId, branchGroups }; - try { - window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); - } catch (error) { - console.error("[GlobalChatbox] Failed to persist chat state:", error); - } - }, [branchGroups, messages, sessionId]); + let cancelled = false; + + const hydrate = async () => { + try { + const [loadedState, sessions] = await Promise.all([ + loadActiveChatState(), + listChatSessions(), + ]); + if (cancelled) return; + + storageSessionIdRef.current = loadedState.storageSessionId; + sessionIdRef.current = loadedState.sessionId; + hydrationCompletedRef.current = true; + hydrationNonceRef.current += 1; + + setMessages(loadedState.messages); + setSessionTitle(loadedState.title); + setSessionId(loadedState.sessionId); + setBranchGroups(loadedState.branchGroups); + setChatSessions(sessions); + } catch (error) { + console.error("[GlobalChatbox] Failed to hydrate chat state:", error); + } finally { + if (!cancelled) { + setIsHydrating(false); + } + } + }; + + void hydrate(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (isHydrating || !hydrationCompletedRef.current) return; + + const currentHydrationNonce = hydrationNonceRef.current; + const persistTimer = window.setTimeout(() => { + const state: LoadedChatState = { + storageSessionId: storageSessionIdRef.current, + title: sessionTitle, + messages, + sessionId, + branchGroups, + }; + + void saveActiveChatState(state) + .then((storageSessionId) => { + if (hydrationNonceRef.current !== currentHydrationNonce) return; + storageSessionIdRef.current = storageSessionId; + return listChatSessions(); + }) + .then((sessions) => { + if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return; + setChatSessions(sessions); + }) + .catch((error) => { + console.error("[GlobalChatbox] Failed to persist chat state:", error); + }); + }, 150); + + return () => { + window.clearTimeout(persistTimer); + }; + }, [branchGroups, isHydrating, messages, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -182,7 +246,7 @@ export const useAgentChatSession = ({ assistantMessage, }: PromptRunOptions) => { const prompt = rawPrompt.trim(); - if (!prompt || isStreaming) return; + if (!prompt || isStreaming || isHydrating) return; await cancelPromiseRef.current?.catch(() => undefined); onBeforeSend?.(); @@ -240,6 +304,11 @@ export const useAgentChatSession = ({ assistantMessageId: nextAssistantMessage.id, appendArtifact, }); + } else if (event.type === "session_title") { + const nextTitle = event.title.trim(); + if (nextTitle) { + setSessionTitle(nextTitle); + } } else if (event.type === "done") { setMessages((prev) => prev.map((message) => { @@ -321,7 +390,7 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, isStreaming, messages, onBeforeSend, onToolCall], + [appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall], ); const abort = useCallback(() => { @@ -356,13 +425,115 @@ export const useAgentChatSession = ({ cancelPromiseRef.current = trackedCancelPromise; } setMessages([]); + setSessionTitle(undefined); setBranchGroups([]); setBranchTransition(null); setSessionId(undefined); sessionIdRef.current = undefined; + storageSessionIdRef.current = undefined; setIsStreaming(false); }, []); + const createSession = useCallback(async () => { + if (isHydrating || isStreaming) return; + + const controller = abortRef.current; + controller?.abort(); + setBranchTransition(null); + + const newState = await createEmptyChatSession(); + const sessions = await listChatSessions(); + + hydrationNonceRef.current += 1; + storageSessionIdRef.current = newState.storageSessionId; + sessionIdRef.current = newState.sessionId; + setMessages(newState.messages); + setSessionTitle(newState.title); + setSessionId(newState.sessionId); + setBranchGroups(newState.branchGroups); + setChatSessions(sessions); + setIsStreaming(false); + }, [isHydrating, isStreaming]); + + const switchSession = useCallback( + async (nextStorageSessionId: string) => { + if (isHydrating || isStreaming || storageSessionIdRef.current === nextStorageSessionId) { + return; + } + + setIsHydrating(true); + try { + const [nextState, sessions] = await Promise.all([ + loadChatSessionById(nextStorageSessionId), + listChatSessions(), + ]); + + hydrationNonceRef.current += 1; + storageSessionIdRef.current = nextState.storageSessionId; + sessionIdRef.current = nextState.sessionId; + setBranchTransition(null); + setMessages(nextState.messages); + setSessionTitle(nextState.title); + setSessionId(nextState.sessionId); + setBranchGroups(nextState.branchGroups); + setChatSessions(sessions); + } catch (error) { + console.error("[GlobalChatbox] Failed to switch chat session:", error); + } finally { + setIsHydrating(false); + } + }, + [isHydrating, isStreaming], + ); + + const removeSession = useCallback( + async (targetStorageSessionId: string) => { + if (isHydrating || isStreaming) return; + + try { + const nextActiveSessionId = await deleteChatSession(targetStorageSessionId); + const sessions = await listChatSessions(); + setChatSessions(sessions); + + if (storageSessionIdRef.current !== targetStorageSessionId) { + return; + } + + if (!nextActiveSessionId) { + hydrationNonceRef.current += 1; + storageSessionIdRef.current = undefined; + sessionIdRef.current = undefined; + setBranchTransition(null); + setMessages([]); + setSessionTitle(undefined); + setSessionId(undefined); + setBranchGroups([]); + return; + } + + setIsHydrating(true); + const [nextState, sessionsAfterDelete] = await Promise.all([ + loadChatSessionById(nextActiveSessionId), + listChatSessions(), + ]); + hydrationNonceRef.current += 1; + storageSessionIdRef.current = nextState.storageSessionId; + sessionIdRef.current = nextState.sessionId; + setBranchTransition(null); + setMessages(nextState.messages); + setSessionTitle(nextState.title); + setSessionId(nextState.sessionId); + setBranchGroups(nextState.branchGroups); + setChatSessions(sessionsAfterDelete); + } catch (error) { + console.error("[GlobalChatbox] Failed to delete chat session:", error); + } finally { + setIsHydrating(false); + } + }, + [isHydrating, isStreaming], + ); + const sendPrompt = useCallback( async (rawPrompt: string) => { await runPrompt({ prompt: rawPrompt }); @@ -371,7 +542,7 @@ export const useAgentChatSession = ({ ); const regenerate = useCallback(async () => { - if (isStreaming || messages.length === 0) return; + if (isHydrating || isStreaming || messages.length === 0) return; let lastUserIndex = messages.length - 1; while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") { @@ -400,11 +571,11 @@ export const useAgentChatSession = ({ userMessage: nextUserMessage, assistantMessage: nextAssistantMessage, }); - }, [isStreaming, messages, runPrompt]); + }, [isHydrating, isStreaming, messages, runPrompt]); const editAndResubmit = useCallback( async (messageId: string, newContent: string) => { - if (isStreaming) return; + if (isHydrating || isStreaming) return; const trimmedContent = newContent.trim(); if (!trimmedContent) return; @@ -483,12 +654,12 @@ export const useAgentChatSession = ({ assistantMessage: nextAssistantMessage, }); }, - [isStreaming, messages, runPrompt], + [isHydrating, isStreaming, messages, runPrompt], ); const cycleBranch = useCallback( (rootMessageId: string, direction: -1 | 1) => { - if (isStreaming) return; + if (isHydrating || isStreaming) return; setBranchGroups((prev) => { const next = cloneBranchGroups(prev); @@ -519,13 +690,16 @@ export const useAgentChatSession = ({ return next; }); }, - [isStreaming, messages], + [isHydrating, isStreaming, messages], ); return { messages, + chatSessions, + activeStorageSessionId: storageSessionIdRef.current, branchGroups, branchTransition, + isHydrating, isStreaming, sessionId, sendPrompt, @@ -533,6 +707,9 @@ export const useAgentChatSession = ({ editAndResubmit, cycleBranch, abort, + createSession, reset, + removeSession, + switchSession, }; }; diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 187cd59..f32a61b 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -4,6 +4,7 @@ import { config } from "@config/config"; export type StreamEvent = | { type: "token"; sessionId: string; content: string } | { type: "done"; sessionId: string } + | { type: "session_title"; sessionId: string; title: string } | { type: "progress"; sessionId: string; @@ -182,6 +183,12 @@ export const streamAgentChat = async ({ type: "done", sessionId: parsed.session_id ?? "", }); + } else if (event === "session_title") { + onEvent({ + type: "session_title", + sessionId: parsed.session_id ?? "", + title: typeof parsed.title === "string" ? parsed.title : "", + }); } else if (event === "error") { onEvent({ type: "error", -- 2.54.0 From ba66abb4ee18c88044569cbf8d6b65689ead13e1 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 16:11:56 +0800 Subject: [PATCH 123/281] ci: improve deploy webhook diagnostics --- .gitea/workflows/package.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 26fd48d..05ab812 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -59,11 +59,12 @@ jobs: REGISTRY_HOST="${REGISTRY_HOST#https://}" REGISTRY_HOST="${REGISTRY_HOST%/}" REPOSITORY_PATH="${RAW_REPOSITORY#/}" - REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" - IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}" + IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" + IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}" { echo "REGISTRY_HOST=${REGISTRY_HOST}" echo "REPOSITORY_PATH=${REPOSITORY_PATH}" + echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}" echo "IMAGE_NAME=${IMAGE_NAME}" echo "IMAGE_TAG=${IMAGE_TAG}" echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}" @@ -116,10 +117,17 @@ jobs: - name: Notify Deploy Server run: | - curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ + http_code=$(curl -sS -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ - -d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" + -d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}") + + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "Deploy webhook failed with HTTP ${http_code}" + echo "Response body:" + cat /tmp/deploy_response.txt + exit 1 + fi deploy-fallback-log: runs-on: ubuntu-22.04 -- 2.54.0 From d4050a841b41e4f52d90628b8ed6d9ac78b1f11d Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 16:18:22 +0800 Subject: [PATCH 124/281] ci: add webhook fallback and response diagnostics --- .gitea/workflows/package.yml | 43 +++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 05ab812..2be1c1e 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -117,18 +117,45 @@ jobs: - name: Notify Deploy Server run: | - http_code=$(curl -sS -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ - -d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}") + post_deploy_webhook() { + label="$1" + payload="$2" - if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then - echo "Deploy webhook failed with HTTP ${http_code}" - echo "Response body:" + http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \ + -d "$payload") + + echo "[$label] webhook HTTP status: ${http_code}" + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + return 0 + fi + + echo "[$label] response headers:" + cat /tmp/deploy_headers.txt + echo "[$label] response body:" cat /tmp/deploy_response.txt - exit 1 + return 1 + } + + PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" + FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}" + + echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}" + echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}" + if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then + exit 0 fi + echo "Primary webhook request failed, retrying with lowercase repo path..." + echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}" + if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then + exit 0 + fi + + echo "Deploy webhook failed after primary and fallback attempts." + exit 1 + deploy-fallback-log: runs-on: ubuntu-22.04 needs: docker-image -- 2.54.0 From 5cfb7cc38ff635951c75f043bdb09515bd5278bb Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Apr 2026 16:49:27 +0800 Subject: [PATCH 125/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E6=A0=87=E7=AD=BE=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <copilot@github.com> --- .gitea/workflows/package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 2be1c1e..6400def 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*" + - "latest" jobs: docker-image: -- 2.54.0 From cf43700459aaa9e707aa0d32d79c185ca68e630c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 8 May 2026 17:22:54 +0800 Subject: [PATCH 126/281] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=A0=87=E9=A2=98=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A6=96=E8=BD=AE=E5=AF=B9=E8=AF=9D=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/chatStorage.ts | 33 ++++++++++++------- .../chat/hooks/useAgentChatSession.ts | 21 ++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 1a540e5..3574362 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -68,14 +68,6 @@ const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({ updatedAt: session.updatedAt, }); -const buildSessionTitle = (messages: Message[]) => { - const firstUserMessage = messages.find((message) => message.role === "user"); - if (!firstUserMessage) return "新对话"; - const title = firstUserMessage.content.replace(/\s+/g, " ").trim(); - if (!title) return "新对话"; - return title.length > 24 ? `${title.slice(0, 24)}...` : title; -}; - const getDb = () => openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, { upgrade(db) { @@ -170,7 +162,7 @@ const migrateLegacyLocalStorage = async () => { const now = Date.now(); const sessionRecord: ChatSessionRecord = { id: createId(), - title: buildSessionTitle(legacyState.messages), + title: "新对话", createdAt: now, updatedAt: now, sessionId: legacyState.sessionId, @@ -244,9 +236,8 @@ export const saveActiveChatState = async ( const now = Date.now(); const storageSessionId = state.storageSessionId ?? createId(); - const computedTitle = buildSessionTitle(state.messages); const preferredTitle = state.title?.trim(); - const finalTitle = preferredTitle || computedTitle; + const finalTitle = preferredTitle || existingSession?.title || "新对话"; const nextRecord: ChatSessionRecord = { id: storageSessionId, title: finalTitle, @@ -278,6 +269,26 @@ export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { .map(toSessionSummary); }; +export const updateChatSessionTitle = async ( + storageSessionId: string, + title: string, +): Promise<void> => { + if (typeof window === "undefined") return; + + const normalizedTitle = title.trim(); + if (!normalizedTitle) return; + + const db = await getDb(); + const session = await db.get(SESSION_STORE, storageSessionId); + if (!session) return; + + await db.put(SESSION_STORE, { + ...session, + title: normalizedTitle, + updatedAt: Date.now(), + }); +}; + export const createEmptyChatSession = async (): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index f3105cd..f4219a2 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -25,6 +25,7 @@ import { loadActiveChatState, loadChatSessionById, saveActiveChatState, + updateChatSessionTitle, } from "../chatStorage"; type UseAgentChatSessionOptions = { @@ -110,6 +111,7 @@ export const useAgentChatSession = ({ const abortRef = useRef<AbortController | null>(null); const sessionIdRef = useRef<string | undefined>(undefined); const cancelPromiseRef = useRef<Promise<void> | null>(null); + const titleUpdateNonceRef = useRef(0); useEffect(() => { sessionIdRef.current = sessionId; @@ -130,6 +132,7 @@ export const useAgentChatSession = ({ sessionIdRef.current = loadedState.sessionId; hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; + titleUpdateNonceRef.current += 1; setMessages(loadedState.messages); setSessionTitle(loadedState.title); @@ -308,6 +311,19 @@ export const useAgentChatSession = ({ const nextTitle = event.title.trim(); if (nextTitle) { setSessionTitle(nextTitle); + const currentStorageSessionId = storageSessionIdRef.current; + if (currentStorageSessionId) { + const currentNonce = ++titleUpdateNonceRef.current; + void updateChatSessionTitle(currentStorageSessionId, nextTitle) + .then(() => listChatSessions()) + .then((sessions) => { + if (titleUpdateNonceRef.current !== currentNonce) return; + setChatSessions(sessions); + }) + .catch((error) => { + console.error("[GlobalChatbox] Failed to persist session title:", error); + }); + } } } else if (event.type === "done") { setMessages((prev) => @@ -431,6 +447,7 @@ export const useAgentChatSession = ({ setSessionId(undefined); sessionIdRef.current = undefined; storageSessionIdRef.current = undefined; + titleUpdateNonceRef.current += 1; setIsStreaming(false); }, []); @@ -445,6 +462,7 @@ export const useAgentChatSession = ({ const sessions = await listChatSessions(); hydrationNonceRef.current += 1; + titleUpdateNonceRef.current += 1; storageSessionIdRef.current = newState.storageSessionId; sessionIdRef.current = newState.sessionId; setMessages(newState.messages); @@ -469,6 +487,7 @@ export const useAgentChatSession = ({ ]); hydrationNonceRef.current += 1; + titleUpdateNonceRef.current += 1; storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; setBranchTransition(null); @@ -501,6 +520,7 @@ export const useAgentChatSession = ({ if (!nextActiveSessionId) { hydrationNonceRef.current += 1; + titleUpdateNonceRef.current += 1; storageSessionIdRef.current = undefined; sessionIdRef.current = undefined; setBranchTransition(null); @@ -517,6 +537,7 @@ export const useAgentChatSession = ({ listChatSessions(), ]); hydrationNonceRef.current += 1; + titleUpdateNonceRef.current += 1; storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; setBranchTransition(null); -- 2.54.0 From 133f5d417fcc27648491439d2edaf2f38e0ba17e Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 8 May 2026 17:42:21 +0800 Subject: [PATCH 127/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=86=E4=B8=BA=E4=B8=B4=E6=97=B6=E6=A8=A1=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=BB=9A=E5=8A=A8=E5=92=8C=E8=BF=87=E6=B8=A1?= =?UTF-8?q?=E6=95=88=E6=9E=9C=EF=BC=8C=E9=81=BF=E5=85=8D=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=A8=AA=E5=90=91=E6=8B=89=E4=BC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/globals.css | 1 + src/components/chat/GlobalChatbox.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 2fea8fa..b626488 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,4 +6,5 @@ body { height: 100%; margin: 0; padding: 0; + overflow-x: hidden; } diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index b636dff..21b87ae 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -155,19 +155,21 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { return ( <Drawer anchor="right" - variant="persistent" + variant="temporary" open={open} onClose={onClose} hideBackdrop + disableScrollLock + disableEnforceFocus sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }} PaperProps={{ sx: { width: { xs: "100%", sm: width }, background: "transparent", boxShadow: "none", - overflow: "visible", + overflow: open ? "visible" : "hidden", zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, - transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", + transition: isResizing ? "none" : undefined, }, }} > -- 2.54.0 From 536cd6a5d1626cb7af3558dcebde6b019818890b Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 11 May 2026 16:37:55 +0800 Subject: [PATCH 128/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7=20ID=20=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8CA?= =?UTF-8?q?gent=20chat=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BC=A0=E9=80=92=20userId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/authToken.ts | 27 +++++++++++++++++++++++++++ src/lib/chatStream.ts | 3 +++ src/lib/requestHeaders.ts | 11 ++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/lib/authToken.ts b/src/lib/authToken.ts index ee0f15c..72bbff5 100644 --- a/src/lib/authToken.ts +++ b/src/lib/authToken.ts @@ -49,3 +49,30 @@ export const getAccessToken = async () => { } return null; }; + +export const getUserId = async () => { + const session = await getSession(); + const sessionUserId = typeof session?.user?.id === "string" ? session.user.id : null; + if (sessionUserId) { + return sessionUserId; + } + + const accessToken = await getAccessToken(); + if (!accessToken) { + return null; + } + + const payload = decodeJwtPayload(accessToken); + if (!payload || typeof payload !== "object") { + return null; + } + + const candidate = + typeof payload.sub === "string" + ? payload.sub + : typeof payload.user_id === "string" + ? payload.user_id + : null; + + return candidate; +}; diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index f32a61b..2da5472 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -99,6 +99,7 @@ export const streamAgentChat = async ({ session_id: sessionId, }), projectHeaderMode: "include", + userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -229,6 +230,7 @@ export const abortAgentChat = async (sessionId?: string) => { session_id: sessionId, }), projectHeaderMode: "include", + userHeaderMode: "include", skipAuthRedirect: true, }); @@ -249,6 +251,7 @@ export const forkAgentChat = async (sessionId: string | undefined, keepMessageCo keep_message_count: keepMessageCount, }), projectHeaderMode: "include", + userHeaderMode: "include", skipAuthRedirect: true, }); diff --git a/src/lib/requestHeaders.ts b/src/lib/requestHeaders.ts index 95e45ac..541ce4d 100644 --- a/src/lib/requestHeaders.ts +++ b/src/lib/requestHeaders.ts @@ -1,12 +1,14 @@ -import { getAccessToken } from "@/lib/authToken"; +import { getAccessToken, getUserId } from "@/lib/authToken"; import { useProjectStore } from "@/store/projectStore"; export type AuthHeaderMode = "include" | "omit"; export type ProjectHeaderMode = "auto" | "include" | "omit"; +export type UserHeaderMode = "include" | "omit"; export interface AuthContextHeaderOptions { authHeaderMode?: AuthHeaderMode; projectHeaderMode?: ProjectHeaderMode; + userHeaderMode?: UserHeaderMode; } const shouldIncludeProjectHeader = ( @@ -34,6 +36,13 @@ export const applyAuthContextHeaders = async ( headers.set("Authorization", `Bearer ${accessToken}`); } + if (options.userHeaderMode === "include") { + const userId = await getUserId(); + if (userId) { + headers.set("X-User-Id", userId); + } + } + const projectId = useProjectStore.getState().currentProjectId; if ( projectId && -- 2.54.0 From a4486e3d89e98a45ef265dbf6f7c1b1f590a957f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 13 May 2026 17:43:06 +0800 Subject: [PATCH 129/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=20Agent=20=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E5=B1=95=E7=A4=BA=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=92=8C=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentHistoryPanel.tsx | 3 +- .../chat/AgentProgressTimeline.test.tsx | 70 +++++++++++-- src/components/chat/AgentProgressTimeline.tsx | 97 ++++++++++++++++++- src/components/chat/AgentTurn.tsx | 49 +++++----- src/components/chat/GlobalChatbox.types.ts | 5 + .../chat/hooks/useAgentChatSession.ts | 48 ++++++++- src/lib/chatStream.ts | 18 +++- 7 files changed, 249 insertions(+), 41 deletions(-) diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index e29fdfd..21b8dde 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -317,6 +317,7 @@ export const AgentHistoryPanel = ({ <Dialog open={isDeleteDialogOpen} onClose={() => setIsDeleteDialogOpen(false)} + sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }} TransitionProps={{ onExited: () => setPendingDeleteSessionId(null) }} @@ -346,7 +347,7 @@ export const AgentHistoryPanel = ({ > <WarningRounded sx={{ fontSize: 22 }} /> </Box> - <Typography variant="h6" fontWeight={800} color="text.primary"> + <Typography component="span" variant="h6" fontWeight={800} color="text.primary"> 删除确认 </Typography> </DialogTitle> diff --git a/src/components/chat/AgentProgressTimeline.test.tsx b/src/components/chat/AgentProgressTimeline.test.tsx index 60cf369..b1e7b54 100644 --- a/src/components/chat/AgentProgressTimeline.test.tsx +++ b/src/components/chat/AgentProgressTimeline.test.tsx @@ -5,13 +5,26 @@ import { AgentProgressTimeline } from "./AgentProgressTimeline"; import type { ChatProgress } from "./GlobalChatbox.types"; describe("AgentProgressTimeline", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it("shows the running step and keeps the timeline expanded while running", () => { + const now = Date.now(); const progress: ChatProgress[] = [ { id: "start", phase: "start", - status: "completed", + status: "running", title: "收到请求", + startedAt: now - 5000, + elapsedMs: 5000, + elapsedSnapshotAt: now, }, { id: "tool", @@ -19,42 +32,79 @@ describe("AgentProgressTimeline", () => { status: "running", title: "正在调用 dynamic_http_call", detail: "GET /api/v1/network/bottlenecks", + startedAt: now - 1200, + elapsedMs: 1200, + elapsedSnapshotAt: now, }, ]; render(<AgentProgressTimeline progress={progress} />); - expect(screen.getByText("Agent 过程")).toBeInTheDocument(); - expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument(); + expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument(); + expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument(); expect(screen.getByText("查询后端数据")).toBeInTheDocument(); expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument(); + expect(screen.getByText("1.2s")).toBeInTheDocument(); }); it("summarizes completed steps and lets users expand details", async () => { const progress: ChatProgress[] = [ - { id: "start", phase: "start", status: "completed", title: "收到请求" }, - { id: "done", phase: "complete", status: "completed", title: "分析完成" }, + { + id: "request-received", + phase: "start", + status: "completed", + title: "收到请求", + startedAt: Date.now() - 8000, + endedAt: Date.now(), + durationMs: 8000, + }, + { + id: "done", + phase: "complete", + status: "completed", + title: "分析完成", + startedAt: Date.now() - 1000, + endedAt: Date.now(), + durationMs: 1000, + }, ]; render(<AgentProgressTimeline progress={progress} />); - expect(screen.getByText("已完成 2 步")).toBeInTheDocument(); + expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument(); + expect(screen.getByText(/耗时 8.0s/)).toBeInTheDocument(); expect(screen.queryByText("分析完成")).not.toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "展开" })); + fireEvent.click(screen.getByText(/Agent 过程:/)); expect(screen.getByText("分析完成")).toBeVisible(); }); it("treats stale running steps as finished after a complete event", () => { const progress: ChatProgress[] = [ - { id: "tool", phase: "tool", status: "running", title: "正在调用 dynamic_http_call" }, - { id: "done", phase: "complete", status: "completed", title: "分析完成" }, + { + id: "tool", + phase: "tool", + status: "completed", + title: "正在调用 dynamic_http_call", + startedAt: Date.now() - 4000, + endedAt: Date.now(), + }, + { + id: "done", + phase: "complete", + status: "completed", + title: "分析完成", + startedAt: Date.now() - 500, + endedAt: Date.now(), + durationMs: 500, + }, ]; render(<AgentProgressTimeline progress={progress} />); - expect(screen.getByText("已完成 2 步")).toBeInTheDocument(); + expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument(); + expect(screen.getByText("4.0s")).toBeInTheDocument(); expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); }); }); diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index 7cbb327..1b69d68 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Collapse, @@ -22,6 +22,46 @@ import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRound import type { ChatProgress } from "./GlobalChatbox.types"; +const formatDuration = (durationMs: number) => { + if (!Number.isFinite(durationMs) || durationMs < 0) { + return "0s"; + } + if (durationMs < 10_000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } + const totalSeconds = Math.round(durationMs / 1000); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) { + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; + } + const hours = Math.floor(minutes / 60); + const remainMinutes = minutes % 60; + return `${hours}h ${remainMinutes.toString().padStart(2, "0")}m`; +}; + +const getProgressElapsedMs = (item: ChatProgress, nowMs: number) => { + if (item.durationMs !== undefined) { + return item.durationMs; + } + if (item.status === "running") { + if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) { + return Math.max(0, item.elapsedMs + (nowMs - item.elapsedSnapshotAt)); + } + if (item.startedAt !== undefined) { + return Math.max(0, nowMs - item.startedAt); + } + return item.elapsedMs; + } + if (item.startedAt !== undefined && item.endedAt !== undefined) { + return Math.max(0, item.endedAt - item.startedAt); + } + return item.elapsedMs; +}; + const phaseIcon = (phase: string, status: ChatProgress["status"]) => { const sx = { fontSize: 16 }; if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />; @@ -46,6 +86,7 @@ const formatToolTitle = (item: ChatProgress) => { export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => { const theme = useTheme(); + const [nowMs, setNowMs] = useState(() => Date.now()); // 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功) const isOverallComplete = progress.some( @@ -55,6 +96,16 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP // 修正状态判断:如果外部标记为中断,或者没有完成标记 const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running"); const hasError = isAborted || progress.some((item) => item.status === "error"); + + useEffect(() => { + if (!hasRunning) { + return; + } + const timer = window.setInterval(() => { + setNowMs(Date.now()); + }, 500); + return () => window.clearInterval(timer); + }, [hasRunning]); // 展开状态逻辑:默认折叠,保持界面整洁 const [expanded, setExpanded] = useState(false); @@ -70,6 +121,31 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP return `已执行 ${progress.length} 步`; }, [isOverallComplete, hasError, progress, isAborted]); + const totalDurationLabel = useMemo(() => { + const requestProgress = progress.find((item) => item.id === "request-received"); + const requestElapsed = + requestProgress ? getProgressElapsedMs(requestProgress, nowMs) : undefined; + if (requestElapsed !== undefined) { + return formatDuration(requestElapsed); + } + const startedAtValues = progress + .map((item) => item.startedAt) + .filter((value): value is number => value !== undefined); + if (startedAtValues.length === 0) { + return undefined; + } + const minStartedAt = Math.min(...startedAtValues); + const endedAtValues = progress + .map((item) => item.endedAt) + .filter((value): value is number => value !== undefined); + const endAnchor = isOverallComplete + ? endedAtValues.length > 0 + ? Math.max(...endedAtValues) + : nowMs + : nowMs; + return formatDuration(Math.max(0, endAnchor - minStartedAt)); + }, [isOverallComplete, nowMs, progress]); + // 根据整体状态决定顶部卡片的颜色主题 const statusColor = isOverallComplete ? "#4caf50" // Success Green @@ -120,6 +196,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP <Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}> Agent 过程: {summary} + {totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""} </Typography> <KeyboardArrowDownRounded @@ -159,6 +236,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP {progress.map((item, index) => { const isLast = index === progress.length - 1; const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount; + const stepElapsedMs = getProgressElapsedMs(item, nowMs); const itemColor = isAborted && isLast ? theme.palette.error.main @@ -219,9 +297,20 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP </Box> </Box> <Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}> - <Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}> - {item.phase === "tool" ? formatToolTitle(item) : item.title} - </Typography> + <Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between"> + <Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}> + {item.phase === "tool" ? formatToolTitle(item) : item.title} + </Typography> + {stepElapsedMs !== undefined ? ( + <Typography + variant="caption" + color="text.secondary" + sx={{ fontSize: "0.68rem", fontFamily: "var(--font-mono, monospace)" }} + > + {formatDuration(stepElapsedMs)} + </Typography> + ) : null} + </Stack> {item.detail && ( <Collapse in={expanded || isLast} timeout="auto"> diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 65cc1d9..8083954 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -12,6 +12,7 @@ import { IconButton, Paper, Stack, + Tooltip, Typography, alpha, useTheme, @@ -411,7 +412,7 @@ export const AgentTurn = React.memo( </Stack> <AnimatePresence> - {isHovered && !isErrorMessage && ( + {isHovered && ( <motion.div initial={{ opacity: 0, scale: 0.9, y: 5 }} animate={{ opacity: 1, scale: 1, y: 0 }} @@ -432,27 +433,31 @@ export const AgentTurn = React.memo( boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`, }} > - <IconButton - size="small" - aria-label="复制" - onClick={() => { - navigator.clipboard.writeText(message.content); - // Could add a toast here - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - <ContentCopyRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton - size="small" - aria-label="重新生成" - onClick={() => { - onRegenerate(); - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - <RefreshRounded sx={{ fontSize: 16 }} /> - </IconButton> + <Tooltip title="复制"> + <IconButton + size="small" + aria-label="复制" + onClick={() => { + navigator.clipboard.writeText(message.content); + // Could add a toast here + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + <ContentCopyRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Tooltip> + <Tooltip title="重新生成"> + <IconButton + size="small" + aria-label="重新生成" + onClick={() => { + onRegenerate(); + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + <RefreshRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Tooltip> </Paper> </motion.div> )} diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 35e75a9..4fae4b3 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -4,6 +4,11 @@ export type ChatProgress = { status: "running" | "completed" | "error"; title: string; detail?: string; + startedAt?: number; + endedAt?: number; + elapsedMs?: number; + elapsedSnapshotAt?: number; + durationMs?: number; }; export type AgentArtifactKind = "chart" | "map" | "panel" | "tool"; diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index f4219a2..e614d57 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -53,12 +53,39 @@ const upsertProgress = ( ) => { const next = [...(progress ?? [])]; const index = next.findIndex((item) => item.id === event.id); + const existing = index >= 0 ? next[index] : undefined; + const now = Date.now(); + const startedAt = event.startedAt ?? existing?.startedAt; + const isRunning = event.status === "running"; + const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now; + const elapsedMs = isRunning + ? event.elapsedMs ?? + existing?.elapsedMs ?? + (startedAt !== undefined ? Math.max(0, now - startedAt) : undefined) + : undefined; + const elapsedSnapshotAt = isRunning + ? event.elapsedMs !== undefined + ? now + : existing?.elapsedSnapshotAt ?? now + : undefined; + const durationMs = !isRunning + ? event.durationMs ?? + existing?.durationMs ?? + (startedAt !== undefined && endedAt !== undefined + ? Math.max(0, endedAt - startedAt) + : undefined) + : undefined; const nextItem: ChatProgress = { id: event.id, phase: event.phase, status: event.status, title: event.title, detail: event.detail, + startedAt, + endedAt, + elapsedMs, + elapsedSnapshotAt, + durationMs, }; if (index >= 0) { next[index] = nextItem; @@ -69,9 +96,24 @@ const upsertProgress = ( }; const completeRunningProgress = (progress: ChatProgress[] | undefined) => - progress?.map((item) => - item.status === "running" ? { ...item, status: "completed" as const } : item, - ); + progress?.map((item) => { + if (item.status !== "running") { + return item; + } + const endedAt = Date.now(); + return { + ...item, + status: "completed" as const, + endedAt, + elapsedMs: undefined, + elapsedSnapshotAt: undefined, + durationMs: + item.durationMs ?? + (item.startedAt !== undefined + ? Math.max(0, endedAt - item.startedAt) + : item.elapsedMs), + }; + }); const createUserMessage = (content: string, branchRootId?: string): Message => { const id = createId(); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 2da5472..eec1856 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -3,7 +3,7 @@ import { config } from "@config/config"; export type StreamEvent = | { type: "token"; sessionId: string; content: string } - | { type: "done"; sessionId: string } + | { type: "done"; sessionId: string; totalDurationMs?: number } | { type: "session_title"; sessionId: string; title: string } | { type: "progress"; @@ -13,12 +13,17 @@ export type StreamEvent = status: "running" | "completed" | "error"; title: string; detail?: string; + startedAt?: number; + endedAt?: number; + elapsedMs?: number; + durationMs?: number; } | { type: "error"; sessionId?: string; message: string; detail?: string; + totalDurationMs?: number; } | { type: "tool_call"; @@ -162,6 +167,11 @@ export const streamAgentChat = async ({ phase?: string; status?: "running" | "completed" | "error"; title?: string; + started_at?: number; + ended_at?: number; + elapsed_ms?: number; + duration_ms?: number; + total_duration_ms?: number; }; if (event === "token") { onEvent({ @@ -178,11 +188,16 @@ export const streamAgentChat = async ({ status: parsed.status ?? "running", title: parsed.title ?? "正在处理", detail: parsed.detail, + startedAt: parsed.started_at, + endedAt: parsed.ended_at, + elapsedMs: parsed.elapsed_ms, + durationMs: parsed.duration_ms, }); } else if (event === "done") { onEvent({ type: "done", sessionId: parsed.session_id ?? "", + totalDurationMs: parsed.total_duration_ms, }); } else if (event === "session_title") { onEvent({ @@ -196,6 +211,7 @@ export const streamAgentChat = async ({ sessionId: parsed.session_id, message: parsed.message ?? "unknown error", detail: parsed.detail, + totalDurationMs: parsed.total_duration_ms, }); } else if (event === "tool_call") { onEvent({ -- 2.54.0 From 8058b7b859a72d113c57a9109ac0c93cfa666587 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 13 May 2026 18:12:22 +0800 Subject: [PATCH 130/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=20Agent=20=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentComposer.tsx | 207 ++++++++++++++---- src/components/chat/GlobalChatbox.tsx | 7 + .../chat/hooks/useAgentChatSession.ts | 7 +- src/lib/chatStream.test.ts | 6 + src/lib/chatStream.ts | 7 + 5 files changed, 192 insertions(+), 42 deletions(-) diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index 1f4b558..4dd3fbf 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -7,8 +7,11 @@ import { Box, Chip, Collapse, + FormControl, IconButton, + MenuItem, Paper, + Select, Stack, TextField, Typography, @@ -21,6 +24,9 @@ import MicRounded from "@mui/icons-material/MicRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; +import BoltRounded from "@mui/icons-material/BoltRounded"; +import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; +import type { AgentModel } from "@/lib/chatStream"; type AgentComposerProps = { input: string; @@ -36,6 +42,8 @@ type AgentComposerProps = { onStartListening: () => void; onStopListening: () => void; onPresetSelect: (prompt: string) => void; + selectedModel: AgentModel; + onModelChange: (model: AgentModel) => void; }; export const AgentComposer = ({ @@ -52,6 +60,8 @@ export const AgentComposer = ({ onStartListening, onStopListening, onPresetSelect, + selectedModel, + onModelChange, }: AgentComposerProps) => { const theme = useTheme(); const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; @@ -213,46 +223,163 @@ export const AgentComposer = ({ ) : null} </Stack> - <AnimatePresence mode="wait"> - {isStreaming ? ( - <motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> - <IconButton - onClick={onAbort} - aria-label="停止生成" - size="small" - sx={{ - bgcolor: "error.main", - color: "#fff", - width: 40, - height: 40, - boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`, - "&:hover": { bgcolor: "error.dark" }, - }} - > - <StopRounded /> - </IconButton> - </motion.div> - ) : ( - <motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> - <IconButton - disabled={!canSend} - onClick={onSend} - aria-label="发送" - size="small" - sx={{ - bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5), - color: canSend ? "#fff" : "action.disabled", - width: 40, - height: 40, - boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none", - "&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) }, - }} - > - <SendRounded sx={{ ml: 0.35 }} /> - </IconButton> - </motion.div> - )} - </AnimatePresence> + <Stack direction="row" spacing={1} alignItems="center"> + <FormControl size="small" sx={{ minWidth: 80 }}> + <Select + value={selectedModel} + onChange={(event) => onModelChange(event.target.value as AgentModel)} + disabled={isHydrating || isStreaming} + aria-label="模型选择" + renderValue={(val) => ( + <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> + {val === "deepseek/deepseek-v4-flash" ? ( + <BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} /> + ) : ( + <AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} /> + )} + <Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}> + {val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"} + </Typography> + </Box> + )} + MenuProps={{ + anchorOrigin: { vertical: "top", horizontal: "center" }, + transformOrigin: { vertical: "bottom", horizontal: "center" }, + sx: { zIndex: (theme) => theme.zIndex.modal + 110 }, + PaperProps: { + sx: { + mb: 1.5, + width: 230, + borderRadius: 4, + bgcolor: alpha("#fff", 0.85), + backdropFilter: "blur(24px)", + border: `1px solid ${alpha("#fff", 0.9)}`, + boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`, + "& .MuiList-root": { + p: 1, + }, + "& .MuiMenuItem-root": { + px: 1.5, + py: 1.2, + mb: 0.5, + "&:last-child": { mb: 0 }, + borderRadius: 3, + alignItems: "flex-start", + transition: "all 0.2s ease", + "&:hover": { + bgcolor: alpha("#000", 0.03), + }, + "&.Mui-selected": { + bgcolor: alpha("#00acc1", 0.08), + "&:hover": { + bgcolor: alpha("#00acc1", 0.12), + }, + "& .title": { color: "#00838f" }, + "& .icon": { color: "#00acc1" }, + } + } + } + } + }} + sx={{ + height: 36, + borderRadius: "18px", + bgcolor: "transparent", + color: "text.secondary", + transition: "all 0.2s ease", + ".MuiOutlinedInput-notchedOutline": { + border: "none", + }, + ".MuiSelect-select": { + py: 0, + pl: 1, + pr: "28px !important", + display: "flex", + alignItems: "center", + }, + "&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": { + bgcolor: alpha("#000", 0.06), + color: "text.primary", + ".MuiSelect-icon": { + color: "text.primary", + } + }, + ".MuiSelect-icon": { + color: "text.secondary", + right: 4, + transition: "color 0.2s ease", + } + }} + > + <Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}> + <Box + component="img" + src="/deepseek-logo.svg" + alt="DeepSeek" + sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }} + /> + <Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}> + DEEPSEEK V4 + </Typography> + </Box> + <MenuItem value="deepseek/deepseek-v4-flash"> + <BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} /> + <Box> + <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography> + <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography> + </Box> + </MenuItem> + <MenuItem value="deepseek/deepseek-v4-pro"> + <AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} /> + <Box> + <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>专家</Typography> + <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography> + </Box> + </MenuItem> + </Select> + </FormControl> + + <AnimatePresence mode="wait"> + {isStreaming ? ( + <motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> + <IconButton + onClick={onAbort} + aria-label="停止生成" + size="small" + sx={{ + bgcolor: "error.main", + color: "#fff", + width: 40, + height: 40, + boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`, + "&:hover": { bgcolor: "error.dark" }, + }} + > + <StopRounded /> + </IconButton> + </motion.div> + ) : ( + <motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> + <IconButton + disabled={!canSend} + onClick={onSend} + aria-label="发送" + size="small" + sx={{ + bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5), + color: canSend ? "#fff" : "action.disabled", + width: 40, + height: 40, + boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none", + "&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) }, + }} + > + <SendRounded sx={{ ml: 0.35 }} /> + </IconButton> + </motion.div> + )} + </AnimatePresence> + </Stack> </Stack> </Paper> </motion.div> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 21b87ae..fd0c745 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Box, Drawer, alpha, useTheme } from "@mui/material"; +import type { AgentModel } from "@/lib/chatStream"; import { AgentComposer } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; import { AgentHistoryPanel } from "./AgentHistoryPanel"; @@ -19,6 +20,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [selectedModel, setSelectedModel] = useState<AgentModel>( + "deepseek/deepseek-v4-pro", + ); const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); @@ -65,6 +69,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { } = useAgentChatSession({ onToolCall: handleToolCall, onBeforeSend: stopListening, + getModel: () => selectedModel, }); useEffect(() => { @@ -298,6 +303,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onStartListening={startListening} onStopListening={stopListening} onPresetSelect={handlePresetPromptSelect} + selectedModel={selectedModel} + onModelChange={setSelectedModel} /> </Box> </Box> diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index e614d57..a2ff7cf 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream"; -import type { StreamEvent } from "@/lib/chatStream"; +import type { AgentModel, StreamEvent } from "@/lib/chatStream"; import type { AgentArtifact, BranchGroup, @@ -37,6 +37,7 @@ type UseAgentChatSessionOptions = { }, ) => void; onBeforeSend?: () => void; + getModel?: () => AgentModel; }; type PromptRunOptions = { @@ -137,6 +138,7 @@ const messagesEqual = (left: Message[], right: Message[]) => export const useAgentChatSession = ({ onToolCall, onBeforeSend, + getModel, }: UseAgentChatSessionOptions) => { const storageSessionIdRef = useRef<string | undefined>(undefined); const hydrationCompletedRef = useRef(false); @@ -317,6 +319,7 @@ export const useAgentChatSession = ({ await streamAgentChat({ message: prompt, sessionId: sessionIdOverride ?? sessionIdRef.current, + model: getModel?.(), signal: controller.signal, onEvent: (event) => { if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) { @@ -448,7 +451,7 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall], + [appendArtifact, getModel, isHydrating, isStreaming, messages, onBeforeSend, onToolCall], ); const abort = useCallback(() => { diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 6cf3f16..064ead6 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -51,6 +51,7 @@ describe("streamAgentChat", () => { await streamAgentChat({ message: "hi", + model: "deepseek/deepseek-v4-pro", onEvent: (event) => events.push(event), }); @@ -60,6 +61,11 @@ describe("streamAgentChat", () => { method: "POST", projectHeaderMode: "include", skipAuthRedirect: true, + body: JSON.stringify({ + message: "hi", + session_id: undefined, + model: "deepseek/deepseek-v4-pro", + }), }), ); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index eec1856..9a6d981 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -1,6 +1,10 @@ import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; +export type AgentModel = + | "deepseek/deepseek-v4-flash" + | "deepseek/deepseek-v4-pro"; + export type StreamEvent = | { type: "token"; sessionId: string; content: string } | { type: "done"; sessionId: string; totalDurationMs?: number } @@ -35,6 +39,7 @@ export type StreamEvent = type StreamOptions = { message: string; sessionId?: string; + model?: AgentModel; signal?: AbortSignal; onEvent: (event: StreamEvent) => void; }; @@ -85,6 +90,7 @@ const resolveToolParams = ( export const streamAgentChat = async ({ message, sessionId, + model, signal, onEvent, }: StreamOptions) => { @@ -102,6 +108,7 @@ export const streamAgentChat = async ({ body: JSON.stringify({ message, session_id: sessionId, + model, }), projectHeaderMode: "include", userHeaderMode: "include", -- 2.54.0 From 570d2c7de1fb90712c1ec94e23babd2ca3cea2f8 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 15 May 2026 17:32:38 +0800 Subject: [PATCH 131/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=A4=B4=E9=83=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentHeader.tsx | 17 ++++++++++++++--- src/components/chat/GlobalChatbox.tsx | 2 ++ .../chat/hooks/useAgentChatSession.ts | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index 3885d58..be57972 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -18,6 +18,7 @@ import CloseRounded from "@mui/icons-material/CloseRounded"; import HistoryRounded from "@mui/icons-material/HistoryRounded"; type AgentHeaderProps = { + sessionTitle?: string; isStreaming: boolean; isHistoryOpen: boolean; onHistoryToggle: () => void; @@ -26,6 +27,7 @@ type AgentHeaderProps = { }; export const AgentHeader = ({ + sessionTitle, isStreaming, isHistoryOpen, onHistoryToggle, @@ -33,6 +35,7 @@ export const AgentHeader = ({ onClose, }: AgentHeaderProps) => { const theme = useTheme(); + const displayTitle = sessionTitle?.trim() || "TJWater Agent"; return ( <Box @@ -91,7 +94,7 @@ export const AgentHeader = ({ /> </Box> </motion.div> - <Box> + <Box sx={{ minWidth: 0 }}> <Typography variant="h6" fontWeight={800} @@ -100,12 +103,20 @@ export const AgentHeader = ({ backgroundClip: "text", color: "transparent", letterSpacing: -0.3, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: { xs: "calc(100vw - 220px)", sm: 320 }, }} > - TJWater Agent + {displayTitle} </Typography> <Typography variant="caption" color="text.secondary" fontWeight={500}> - {isStreaming ? "正在思考分析任务..." : "基于大模型的水力分析引擎"} + {isStreaming + ? "正在思考分析任务..." + : displayTitle === "TJWater Agent" + ? "基于大模型的水力分析引擎" + : "当前会话标题"} </Typography> </Box> </Stack> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index fd0c745..f6985fe 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -58,6 +58,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { branchTransition, isHydrating, isStreaming, + sessionTitle, sendPrompt, regenerate, editAndResubmit, @@ -220,6 +221,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} /> <AgentHeader + sessionTitle={sessionTitle} isStreaming={isStreaming} isHistoryOpen={isHistoryOpen} onHistoryToggle={handleHistoryToggle} diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index a2ff7cf..08e6f97 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -767,6 +767,7 @@ export const useAgentChatSession = ({ branchTransition, isHydrating, isStreaming, + sessionTitle, sessionId, sendPrompt, regenerate, -- 2.54.0 From 03ca56d2a7a09460b98b207576572d02cfa4f402 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 18 May 2026 15:32:53 +0800 Subject: [PATCH 132/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=A7=A6=E5=8F=91=20?= =?UTF-8?q?Gitea=20=E7=AE=A1=E9=81=93=E7=9A=84=E8=84=9A=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20package.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- scripts/trigger-gitea-pipeline.sh | 43 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100755 scripts/trigger-gitea-pipeline.sh diff --git a/package.json b/package.json index 9080f31..da5bcab 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "refine": "refine" + "refine": "refine", + "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh" }, "dependencies": { "@emotion/react": "^11.8.2", diff --git a/scripts/trigger-gitea-pipeline.sh b/scripts/trigger-gitea-pipeline.sh new file mode 100755 index 0000000..c1394e7 --- /dev/null +++ b/scripts/trigger-gitea-pipeline.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]" + echo "" + echo "Examples:" + echo " bash scripts/trigger-gitea-pipeline.sh" + echo " bash scripts/trigger-gitea-pipeline.sh gitea latest" + echo " bash scripts/trigger-gitea-pipeline.sh gitea v2026.05.15.1" + exit 0 +fi + +REMOTE="${1:-gitea}" +TAG="${2:-latest}" + +if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "[ERROR] Current directory is not a git repository." + exit 1 +fi + +if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then + echo "[ERROR] Remote '$REMOTE' does not exist." + echo "Available remotes:" + git remote -v + exit 1 +fi + +HEAD_SHA="$(git rev-parse --short HEAD)" +MESSAGE="manual trigger: ${TAG} $(date '+%F %T')" + +echo "[INFO] HEAD: ${HEAD_SHA}" +echo "[INFO] Recreate annotated tag '${TAG}'" +git tag -fa "$TAG" -m "$MESSAGE" + +echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)" +git push "$REMOTE" "refs/tags/${TAG}" --force + +echo "[INFO] Verify remote tag reference" +git ls-remote --tags "$REMOTE" "refs/tags/${TAG}" + +echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'." -- 2.54.0 From 45274955c63a3dc985dc0aadcc413b75376288d7 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 18 May 2026 15:44:36 +0800 Subject: [PATCH 133/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=93=8D=E4=BD=9C=E5=92=8C=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentProgressTimeline.tsx | 1 + src/components/chat/ChatToolCallBlock.tsx | 39 +++++ .../chat/hooks/useAgentToolActions.ts | 38 +++++ .../DMALeakDetectionPanel.tsx | 109 ++----------- .../applyJunctionAreaRender.ts | 147 ++++++++++++++++++ .../olmap/core/Controls/Toolbar.tsx | 35 ++++- src/store/chatToolStore.ts | 6 + 7 files changed, 278 insertions(+), 97 deletions(-) create mode 100644 src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index 1b69d68..cecf6fd 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -81,6 +81,7 @@ const formatToolTitle = (item: ChatProgress) => { if (text.includes("locate_features")) return "地图定位"; if (text.includes("view_history")) return "打开历史曲线"; if (text.includes("view_scada")) return "打开 SCADA 面板"; + if (text.includes("render_junctions")) return "渲染节点"; return item.title; }; diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index a05a497..d396a76 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -131,6 +131,12 @@ const TOOL_META: Record<string, ToolMeta> = { actionLabel: "显示", color: "#73c0de", }, + render_junctions: { + label: "渲染节点", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "应用渲染", + color: "#3b82f6", + }, }; /* ---------- helpers ---------- */ @@ -261,6 +267,14 @@ function getToolDescription(toolCall: ToolCall): string { case "show_chart": { return (params.title as string | undefined) ?? "数据图表"; } + case "render_junctions": { + const nodeAreaMap = + params.node_area_map && typeof params.node_area_map === "object" + ? (params.node_area_map as Record<string, unknown>) + : {}; + const areaIds = Array.isArray(params.area_ids) ? params.area_ids : []; + return `${Object.keys(nodeAreaMap).length} 个节点 · ${areaIds.length || new Set(Object.values(nodeAreaMap).map(String)).size} 个分区`; + } default: return ""; } @@ -383,6 +397,31 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { xAxisName: params.x_axis_name as string | undefined, yAxisName: params.y_axis_name as string | undefined, }; + case "render_junctions": { + const nodeAreaMap = + params.node_area_map && typeof params.node_area_map === "object" + ? Object.fromEntries( + Object.entries(params.node_area_map as Record<string, unknown>) + .map(([key, value]) => [String(key), String(value ?? "")]) + .filter(([, value]) => value.trim().length > 0), + ) + : {}; + return { + type: "render_junctions", + nodeAreaMap, + areaIds: Array.isArray(params.area_ids) + ? params.area_ids.map((item) => String(item).trim()).filter(Boolean) + : [], + areaColors: + params.area_colors && typeof params.area_colors === "object" + ? Object.fromEntries( + Object.entries(params.area_colors as Record<string, unknown>) + .map(([key, value]) => [String(key), String(value ?? "")]) + .filter(([, value]) => value.trim().length > 0), + ) + : {}, + }; + } default: return null; } diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts index 7d57e62..6017bb1 100644 --- a/src/components/chat/hooks/useAgentToolActions.ts +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -136,6 +136,26 @@ const resolveTimeRange = (params: Record<string, unknown>) => ({ (params.end as string | undefined), }); +const resolveStringRecord = (value: unknown): Record<string, string> => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record<string, unknown>) + .map(([key, recordValue]) => [String(key), String(recordValue ?? "")]) + .filter(([, recordValue]) => recordValue.trim().length > 0), + ); +}; + +const resolveStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value.map((item) => String(item).trim()).filter(Boolean); +}; + const compactNames = (names: string[]) => { if (!names.length) return ""; return names.length > 3 @@ -230,6 +250,24 @@ const buildToolAction = ( }; } + if (tool === "render_junctions") { + const nodeAreaMap = resolveStringRecord(params.node_area_map); + const areaIds = resolveStringArray(params.area_ids); + const areaColors = resolveStringRecord(params.area_colors); + + return { + action: { + type: "render_junctions", + nodeAreaMap, + areaIds, + areaColors, + }, + kind: "map", + title: "渲染节点分区", + description: `${Object.keys(nodeAreaMap).length} 个节点`, + }; + } + return { action: null, kind: "tool", diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index 40d4c69..f3892d4 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -17,18 +17,14 @@ import { ChevronRight, FormatListBulleted, } from "@mui/icons-material"; -import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; -import VectorTileSource from "ol/source/VectorTile"; -import { VectorTile } from "ol"; -import { FlatStyleLike } from "ol/style/flat"; import { useMap } from "@components/olmap/core/MapComponent"; import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; import RecognitionResults from "./RecognitionResults"; +import { applyJunctionAreaRender } from "./applyJunctionAreaRender"; import { getAreaColor } from "./utils"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; -import { config } from "@/config/config"; const TabPanel = ({ value, @@ -82,101 +78,26 @@ const DMALeakDetectionPanel: React.FC = () => { useEffect(() => { if (!map) return; - const junctionLayer = map - .getAllLayers() - .find( - (layer) => - layer instanceof WebGLVectorTileLayer && layer.get("value") === "junctions", - ) as WebGLVectorTileLayer | undefined; - if (!junctionLayer) return; - const source = junctionLayer.getSource() as VectorTileSource; - if (!source) return; - - if (!loadedResult || !loadedResult.node_area_map) { - junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); - return; - } const fallbackAreaIds = Array.from( - new Set(Object.values(loadedResult.node_area_map || {}).map(String)), + new Set(Object.values(loadedResult?.node_area_map ?? {}).map(String)), ); - const areaIds = (loadedResult.areas || []).length - ? loadedResult.areas.map((area) => String(area.area_id)) + const areaIds = (loadedResult?.areas ?? []).length + ? (loadedResult?.areas ?? []).map((area) => String(area.area_id)) : fallbackAreaIds; - if (areaIds.length === 0) { - junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); - return; - } - - const areaIdToIndex = new Map<string, number>(); - areaIds.forEach((areaId, index) => { - areaIdToIndex.set(areaId, index + 1); - }); - - const nodeAreaIndexMap = new Map<string, number>(); - Object.entries(loadedResult.node_area_map || {}).forEach(([nodeId, areaId]) => { - const idx = areaIdToIndex.get(String(areaId)); - if (idx !== undefined) { - nodeAreaIndexMap.set(String(nodeId), idx); - } - }); - - const applyFeatureAreaIndex = (renderFeature: any) => { - const featureId = String(renderFeature.get("id") ?? ""); - const areaIndex = nodeAreaIndexMap.get(featureId); - if (areaIndex !== undefined) { - renderFeature.properties_[DMA_AREA_INDEX_PROPERTY] = areaIndex; - } - }; - - const sourceTiles = (source as any).sourceTiles_; - if (sourceTiles) { - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - }); - } - - const listener = (event: any) => { - try { - if (event.tile instanceof VectorTile) { - const renderFeatures = event.tile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - } - } catch (error) { - console.error("Error applying DMA area mapping:", error); - } - }; - source.on("tileloadend", listener); - - const fillCases: any[] = []; - areaIds.forEach((areaId, index) => { - fillCases.push( - ["==", ["get", DMA_AREA_INDEX_PROPERTY], index + 1], - getAreaColor(areaId), - ); - }); - const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]); - const defaultStrokeColor = String( - config.MAP_DEFAULT_STYLE["circle-stroke-color"], + const areaColors = Object.fromEntries( + areaIds.map((areaId) => [areaId, getAreaColor(areaId)]), ); - const dmaStyle: FlatStyleLike = { - ...config.MAP_DEFAULT_STYLE, - "circle-fill-color": ["case", ...fillCases, defaultFillColor], - "circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], - }; - junctionLayer.setStyle(dmaStyle); - return () => { - source.un("tileloadend", listener); - junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); - }; + return applyJunctionAreaRender( + map, + { + nodeAreaMap: loadedResult?.node_area_map ?? {}, + areaIds, + areaColors, + }, + { propertyKey: DMA_AREA_INDEX_PROPERTY }, + ); }, [map, loadedResult]); return ( diff --git a/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts new file mode 100644 index 0000000..bd3c7c4 --- /dev/null +++ b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts @@ -0,0 +1,147 @@ +import { Map as OlMap, VectorTile } from "ol"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import VectorTileSource from "ol/source/VectorTile"; +import { FlatStyleLike } from "ol/style/flat"; + +import { config } from "@/config/config"; +import { getAreaColor } from "./utils"; + +const JUNCTION_LAYER_VALUE = "junctions"; +const RENDER_OWNER_KEY = "junction-area-render-owner"; + +export type JunctionAreaRenderPayload = { + nodeAreaMap: Record<string, string>; + areaIds?: string[]; + areaColors?: Record<string, string>; +}; + +type ApplyJunctionAreaRenderOptions = { + propertyKey?: string; +}; + +const DEFAULT_PROPERTY_KEY = "junction_area_render_index"; + +const getJunctionLayer = (map: OlMap) => + map + .getAllLayers() + .find( + (layer) => + layer instanceof WebGLVectorTileLayer && + layer.get("value") === JUNCTION_LAYER_VALUE, + ) as WebGLVectorTileLayer | undefined; + +export const applyJunctionAreaRender = ( + map: OlMap, + payload: JunctionAreaRenderPayload, + options: ApplyJunctionAreaRenderOptions = {}, +) => { + const propertyKey = options.propertyKey ?? DEFAULT_PROPERTY_KEY; + const junctionLayer = getJunctionLayer(map); + if (!junctionLayer) { + return () => {}; + } + + const source = junctionLayer.getSource() as VectorTileSource | null; + if (!source) { + return () => {}; + } + + const ownerId = `${propertyKey}-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; + + const normalizedNodeAreaMap = Object.fromEntries( + Object.entries(payload.nodeAreaMap ?? {}).map(([nodeId, areaId]) => [ + String(nodeId), + String(areaId), + ]), + ); + + const areaIds = ( + payload.areaIds?.length + ? payload.areaIds + : Array.from(new Set(Object.values(normalizedNodeAreaMap))) + ) + .map(String) + .filter(Boolean); + + if (Object.keys(normalizedNodeAreaMap).length === 0 || areaIds.length === 0) { + junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); + return () => {}; + } + + const areaIdToIndex = new Map<string, number>(); + areaIds.forEach((areaId, index) => { + areaIdToIndex.set(areaId, index + 1); + }); + + const nodeAreaIndexMap = new Map<string, number>(); + Object.entries(normalizedNodeAreaMap).forEach(([nodeId, areaId]) => { + const areaIndex = areaIdToIndex.get(areaId); + if (areaIndex !== undefined) { + nodeAreaIndexMap.set(nodeId, areaIndex); + } + }); + + const applyFeatureAreaIndex = (renderFeature: any) => { + const featureId = String(renderFeature.get("id") ?? ""); + const areaIndex = nodeAreaIndexMap.get(featureId); + if (areaIndex !== undefined) { + renderFeature.properties_[propertyKey] = areaIndex; + } + }; + + const sourceTiles = (source as any).sourceTiles_; + if (sourceTiles) { + Object.values(sourceTiles).forEach((vectorTile: any) => { + const renderFeatures = vectorTile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + applyFeatureAreaIndex(renderFeature); + }); + }); + } + + const listener = (event: any) => { + try { + if (!(event.tile instanceof VectorTile)) return; + const renderFeatures = event.tile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + applyFeatureAreaIndex(renderFeature); + }); + } catch (error) { + console.error("Error applying junction area render:", error); + } + }; + + source.on("tileloadend", listener); + + const fillCases: any[] = []; + areaIds.forEach((areaId, index) => { + fillCases.push( + ["==", ["get", propertyKey], index + 1], + payload.areaColors?.[areaId] ?? getAreaColor(areaId), + ); + }); + + const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]); + const defaultStrokeColor = String( + config.MAP_DEFAULT_STYLE["circle-stroke-color"], + ); + + junctionLayer.set(RENDER_OWNER_KEY, ownerId); + junctionLayer.setStyle({ + ...config.MAP_DEFAULT_STYLE, + "circle-fill-color": ["case", ...fillCases, defaultFillColor], + "circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], + } as FlatStyleLike); + + return () => { + source.un("tileloadend", listener); + if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) { + junctionLayer.unset(RENDER_OWNER_KEY, true); + junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); + } + }; +}; diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 58b6721..e2a0747 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { useData, useMap } from "../MapComponent"; import ToolbarButton from "@/components/olmap/common/ToolbarButton"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; @@ -24,6 +24,7 @@ import StyleLegend from "./StyleLegend"; // 引入图例组件 import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService"; import { useNotification } from "@refinedev/core"; import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler"; +import { applyJunctionAreaRender } from "@components/olmap/DMALeakDetection/applyJunctionAreaRender"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; @@ -81,8 +82,16 @@ const Toolbar: React.FC<ToolbarProps> = ({ startTime?: string; endTime?: string; } | null>(null); + const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null); - // Wire up chat tool actions (locate, view_history, view_scada) + const disposeChatJunctionRender = useCallback(() => { + chatJunctionRenderCleanupRef.current?.(); + chatJunctionRenderCleanupRef.current = null; + }, []); + + useEffect(() => () => disposeChatJunctionRender(), [disposeChatJunctionRender]); + + // Wire up chat tool actions (locate, view_history, view_scada, render_junctions) useChatToolActionHandler( useCallback( (action) => { @@ -161,9 +170,29 @@ const Toolbar: React.FC<ToolbarProps> = ({ }); break; } + case "render_junctions": { + disposeChatJunctionRender(); + + if (Object.keys(action.nodeAreaMap).length === 0) { + break; + } + + if (map) { + chatJunctionRenderCleanupRef.current = applyJunctionAreaRender( + map, + { + nodeAreaMap: action.nodeAreaMap, + areaIds: action.areaIds, + areaColors: action.areaColors, + }, + { propertyKey: "chat_junction_render_index" }, + ); + } + break; + } } }, - [map], + [disposeChatJunctionRender, map], ), ); diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index c226489..5d44ecf 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -34,6 +34,12 @@ export type ChatToolAction = series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>; xAxisName?: string; yAxisName?: string; + } + | { + type: "render_junctions"; + nodeAreaMap: Record<string, string>; + areaIds?: string[]; + areaColors?: Record<string, string>; }; interface ChatToolState { -- 2.54.0 From 39ee9a02e5c107d4b5736df713b0f244452386d3 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 18 May 2026 15:49:38 +0800 Subject: [PATCH 134/281] =?UTF-8?q?=E6=8B=86=E5=88=86=E3=80=81=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20Toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/core/Controls/Toolbar.tsx | 566 ++---------------- .../core/Controls/ToolbarHistoryPanel.tsx | 92 +++ .../core/Controls/toolbarFeatureHelpers.ts | 350 +++++++++++ .../core/Controls/useToolbarChatActions.ts | 157 +++++ 4 files changed, 633 insertions(+), 532 deletions(-) create mode 100644 src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx create mode 100644 src/components/olmap/core/Controls/toolbarFeatureHelpers.ts create mode 100644 src/components/olmap/core/Controls/useToolbarChatActions.ts diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index e2a0747..03f38f7 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useData, useMap } from "../MapComponent"; import ToolbarButton from "@/components/olmap/common/ToolbarButton"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; @@ -8,27 +8,24 @@ import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined"; import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined"; import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件 import DrawPanel from "./DrawPanel"; // 引入绘图面板组件 -import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件 -import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel"; import VectorSource from "ol/source/Vector"; import VectorLayer from "ol/layer/Vector"; import { Style, Stroke, Fill, Circle } from "ol/style"; import Feature from "ol/Feature"; -import { GeoJSON } from "ol/format"; -import Point from "ol/geom/Point"; -import { bbox, featureCollection } from "@turf/turf"; import StyleEditorPanel from "./StyleEditorPanel"; import { LayerStyleState } from "./StyleEditorPanel"; import StyleLegend from "./StyleLegend"; // 引入图例组件 -import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService"; +import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import { useNotification } from "@refinedev/core"; -import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler"; -import { applyJunctionAreaRender } from "@components/olmap/DMALeakDetection/applyJunctionAreaRender"; +import ToolbarHistoryPanel from "./ToolbarHistoryPanel"; +import { + buildFeatureProperties, +} from "./toolbarFeatureHelpers"; +import { useToolbarChatActions } from "./useToolbarChatActions"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; -import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; // 添加接口定义隐藏按钮的props interface ToolbarProps { @@ -82,119 +79,15 @@ const Toolbar: React.FC<ToolbarProps> = ({ startTime?: string; endTime?: string; } | null>(null); - const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null); - const disposeChatJunctionRender = useCallback(() => { - chatJunctionRenderCleanupRef.current?.(); - chatJunctionRenderCleanupRef.current = null; - }, []); - - useEffect(() => () => disposeChatJunctionRender(), [disposeChatJunctionRender]); - - // Wire up chat tool actions (locate, view_history, view_scada, render_junctions) - useChatToolActionHandler( - useCallback( - (action) => { - const geojsonFormat = new GeoJSON(); - const zoomToFeatures = ( - features: Feature[], - geometryKind: "point" | "line", - ) => { - if (features.length === 0) return; - - if (geometryKind === "point" && features.length === 1) { - const geometry = features[0].getGeometry(); - if (geometry instanceof Point) { - map?.getView().animate({ - center: geometry.getCoordinates(), - zoom: 18, - duration: 1000, - }); - return; - } - } - - const geojsonFeatures = features.map((f) => - geojsonFormat.writeFeatureObject(f), - ); - const extent = bbox(featureCollection(geojsonFeatures as any)); - if (extent) { - map?.getView().fit(extent, { - maxZoom: 18, - duration: 1000, - padding: geometryKind === "line" ? [60, 60, 60, 60] : [40, 40, 40, 40], - }); - } - }; - const locateFeatures = ( - ids: string[], - layer: string, - geometryKind: "point" | "line", - ) => { - queryFeaturesByIds(ids, layer).then((features) => { - if (features.length > 0) { - setHighlightFeatures(features); - zoomToFeatures(features, geometryKind); - } - }); - }; - - switch (action.type) { - case "locate_features": { - locateFeatures(action.ids, action.layer, action.geometryKind); - break; - } - case "view_history": { - setChatPanelFeatureInfos(action.featureInfos); - setChatPanelType(action.dataType); - setChatPanelTimeRange({ - startTime: action.startTime, - endTime: action.endTime, - }); - setShowHistoryPanel(true); - break; - } - case "view_scada": { - setChatPanelFeatureInfos(action.featureInfos); - setChatPanelType("none"); - setChatPanelTimeRange({ - startTime: action.startTime, - endTime: action.endTime, - }); - setShowHistoryPanel(true); - setActiveTools((prev) => { - if (prev.includes("history")) { - return prev; - } - return [...prev, "history"]; - }); - break; - } - case "render_junctions": { - disposeChatJunctionRender(); - - if (Object.keys(action.nodeAreaMap).length === 0) { - break; - } - - if (map) { - chatJunctionRenderCleanupRef.current = applyJunctionAreaRender( - map, - { - nodeAreaMap: action.nodeAreaMap, - areaIds: action.areaIds, - areaColors: action.areaColors, - }, - { propertyKey: "chat_junction_render_index" }, - ); - } - break; - } - } - }, - [disposeChatJunctionRender, map], - ), - ); + useToolbarChatActions({ + setHighlightFeatures, + setChatPanelFeatureInfos, + setChatPanelType, + setChatPanelTimeRange, + setShowHistoryPanel, + setActiveTools, + }); // 样式状态管理 - 在 Toolbar 中管理,带有默认样式 const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([ @@ -556,306 +449,10 @@ const Toolbar: React.FC<ToolbarProps> = ({ if (currentTime !== -1 && queryType) queryComputedProperties(); }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]); - // 从要素属性中提取属性面板需要的数据 - const getFeatureProperties = useCallback(() => { - if (highlightFeatures.length === 0) return {}; - const highlightFeature = highlightFeatures[0]; - const layer = highlightFeature?.getId()?.toString().split(".")[0]; - const properties = highlightFeature.getProperties(); - // 计算属性字段,增加 key 字段 - const pipeComputedFields = [ - { key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` }, - { key: "friction", label: "摩阻", unit: "" }, - { key: "headloss", label: "水头损失", unit: "m" }, - { key: "unit_headloss", label: "单位水头损失", unit: "m/km" }, - { key: "quality", label: "水质", unit: "mg/L" }, - { key: "reaction", label: "反应", unit: "1/d" }, - { key: "setting", label: "设置", unit: "" }, - { key: "status", label: "状态", unit: "" }, - { key: "velocity", label: "流速", unit: "m/s" }, - ]; - const nodeComputedFields = [ - { key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` }, - { key: "total_head", label: "水头", unit: "m" }, - { key: "pressure", label: "压力", unit: "m" }, - { key: "quality", label: "水质", unit: "mg/L" }, - ]; - - if (layer === "geo_pipes_mat" || layer === "geo_pipes") { - let result = { - id: properties.id, - type: "管道", - properties: [ - { label: "起始节点ID", value: properties.node1 }, - { label: "终点节点ID", value: properties.node2 }, - { label: "长度", value: properties.length?.toFixed?.(1), unit: "m" }, - { - label: "管径", - value: properties.diameter?.toFixed?.(1), - unit: "mm", - }, - { label: "粗糙度", value: properties.roughness }, - { label: "局部损失", value: properties.minor_loss }, - { label: "初始状态", value: "开" }, - ], - }; - // 追加计算属性 - if (computedProperties) { - pipeComputedFields.forEach(({ key, label, unit }) => { - let value = computedProperties[key]; - - if (key === "flow" && value !== undefined) { - value = toM3h(value, "lps"); - } - - // 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km) - if ( - key === "unit_headloss" && - value === undefined && - computedProperties.headloss !== undefined && - properties.length - ) { - value = (computedProperties.headloss / properties.length) * 1000; - } - - if (value !== undefined) { - result.properties.push({ - label, - value: typeof value === "number" ? value.toFixed(3) : value, - unit, - }); - } - }); - } - return result; - } - if (layer === "geo_junctions_mat" || layer === "geo_junctions") { - let result = { - id: properties.id, - type: "节点", - properties: [ - { - label: "高程", - value: properties.elevation?.toFixed?.(1), - unit: "m", - }, - // 将 demand1~demand5 与 pattern1~pattern5 作为二级表格展示 - { - type: "table", - label: "基本需水量", - columns: ["demand", "pattern"], - rows: Array.from({ length: 5 }, (_, i) => i + 1) - .map((idx) => { - let d = properties?.[`demand${idx}`]; - const p = properties?.[`pattern${idx}`]; - // 仅当 demand 有效时展示该行 - if (d !== undefined && d !== null && d !== "") { - d = toM3h(Number(d), "lps"); - return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"]; - } - }) - .filter(Boolean) as (string | number)[][], - } as any, - ], - }; - // 追加计算属性 - if (computedProperties) { - nodeComputedFields.forEach(({ key, label, unit }) => { - if (computedProperties[key] !== undefined) { - let value = computedProperties[key]; - if (key === "actual_demand") { - value = toM3h(value, "lps"); - } - result.properties.push({ - label, - value: - value?.toFixed?.(3) || value, - unit, - }); - } - }); - } - return result; - } - if (layer === "geo_tanks_mat" || layer === "geo_tanks") { - return { - id: properties.id, - type: "水池", - properties: [ - { - label: "高程", - value: properties.elevation?.toFixed?.(1), - unit: "m", - }, - { - label: "初始水位", - value: properties.init_level?.toFixed?.(1), - unit: "m", - }, - { - label: "最低水位", - value: properties.min_level?.toFixed?.(1), - unit: "m", - }, - { - label: "最高水位", - value: properties.max_level?.toFixed?.(1), - unit: "m", - }, - { - label: "直径", - value: properties.diameter?.toFixed?.(1), - unit: "m", - }, - { - label: "最小容积", - value: properties.min_vol?.toFixed?.(1), - unit: "m³", - }, - // { - // label: "容积曲线", - // value: properties.vol_curve, - // }, - { - label: "溢出", - value: properties.overflow ? "是" : "否", - }, - ], - }; - } - if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") { - return { - id: properties.id, - type: "水库", - properties: [ - { - label: "水头", - value: properties.head?.toFixed?.(1), - unit: "m", - }, - // { - // label: "模式", - // value: properties.pattern, - // }, - ], - }; - } - if (layer === "geo_pumps_mat" || layer === "geo_pumps") { - return { - id: properties.id, - type: "水泵", - properties: [ - { label: "起始节点 ID", value: properties.node1 }, - { label: "终点节点 ID", value: properties.node2 }, - { - label: "功率", - value: properties.power?.toFixed?.(1), - unit: "kW", - }, - { - label: "扬程", - value: properties.head?.toFixed?.(1), - unit: "m", - }, - { - label: "转速", - value: properties.speed?.toFixed?.(1), - unit: "rpm", - }, - { - label: "模式", - value: properties.pattern, - }, - ], - }; - } - if (layer === "geo_valves_mat" || layer === "geo_valves") { - return { - id: properties.id, - type: "阀门", - properties: [ - { label: "起始节点 ID", value: properties.node1 }, - { label: "终点节点 ID", value: properties.node2 }, - { - label: "直径", - value: properties.diameter?.toFixed?.(1), - unit: "mm", - }, - { - label: "阀门类型", - value: properties.v_type, - }, - // { - // label: "设置", - // value: properties.setting?.toFixed?.(2), - // }, - { - label: "局部损失", - value: properties.minor_loss?.toFixed?.(2), - }, - ], - }; - } - // 传输频率文字对应 - const getTransmissionFrequency = (transmission_frequency: string) => { - // 传输频率文本:00:01:00,00:05:00,00:10:00,00:30:00,01:00:00,转换为分钟数 - const parts = transmission_frequency.split(":"); - if (parts.length !== 3) return transmission_frequency; - const hours = parseInt(parts[0], 10); - const minutes = parseInt(parts[1], 10); - const seconds = parseInt(parts[2], 10); - const totalMinutes = hours * 60 + minutes + (seconds >= 30 ? 1 : 0); - return totalMinutes; - }; - // 可靠度文字映射 - const getReliability = (reliability: number) => { - switch (reliability) { - case 1: - return "高"; - case 2: - return "中"; - case 3: - return "低"; - default: - return "未知"; - } - }; - if (layer === "geo_scada_mat" || layer === "geo_scada") { - let result = { - id: properties.id, - type: "SCADA设备", - properties: [ - { - label: "类型", - value: - properties.type === "pipe_flow" ? "流量传感器" : "压力传感器", - }, - { - label: "关联节点 ID", - value: properties.associated_element_id, - }, - { - label: "传输模式", - value: - properties.transmission_mode === "non_realtime" - ? "定时传输" - : "实时传输", - }, - { - label: "传输频率", - value: getTransmissionFrequency(properties.transmission_frequency), - unit: "分钟", - }, - { - label: "可靠性", - value: getReliability(properties.reliability), - }, - ], - }; - return result; - } - return {}; - }, [highlightFeatures, computedProperties]); + const propertyPanelData = useMemo( + () => buildFeatureProperties(highlightFeatures[0], computedProperties), + [highlightFeatures, computedProperties], + ); if (!data) { return null; @@ -908,7 +505,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ </div> {showPropertyPanel && ( <PropertyPanel - {...getFeatureProperties()} + {...propertyPanelData} onClose={() => { deactivateTool("info"); setActiveTools((prev) => prev.filter((t) => t !== "info")); @@ -922,115 +519,20 @@ const Toolbar: React.FC<ToolbarProps> = ({ setLayerStyleStates={setLayerStyleStates} /> </div> - {showHistoryPanel && - (chatPanelType === "none" && chatPanelFeatureInfos ? ( - <SCADADataPanel - deviceIds={chatPanelFeatureInfos.map(([id]) => id)} - visible={showHistoryPanel} - start_time={chatPanelTimeRange?.startTime} - end_time={chatPanelTimeRange?.endTime} - onClose={() => { - deactivateTool("history"); - setActiveTools((prev) => prev.filter((t) => t !== "history")); - }} - /> - ) : HistoryPanel ? ( - <HistoryPanel - featureInfos={chatPanelFeatureInfos ?? (() => { - if (highlightFeatures.length === 0 || !showHistoryPanel) - return []; - - return highlightFeatures - .map((feature) => { - const properties = feature.getProperties(); - const id = properties.id; - if (!id) return null; - - // 从图层名称推断类型 - const layerId = - feature.getId()?.toString().split(".")[0] || ""; - let type = "unknown"; - - if (layerId.includes("pipe")) { - type = "pipe"; - } else if (layerId.includes("junction")) { - type = "junction"; - } else if (layerId.includes("tank")) { - type = "tank"; - } else if (layerId.includes("reservoir")) { - type = "reservoir"; - } else if (layerId.includes("pump")) { - type = "pump"; - } else if (layerId.includes("valve")) { - type = "valve"; - } - // 仅处理 type 为 pipe 或 junction 的情况 - if (type !== "pipe" && type !== "junction") { - return null; - } - return [id, type]; - }) - .filter(Boolean) as [string, string][]; - })()} - scheme_type="burst_analysis" - scheme_name={schemeName} - type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} - start_time={chatPanelTimeRange?.startTime} - end_time={chatPanelTimeRange?.endTime} - onClose={() => { - deactivateTool("history"); - setActiveTools((prev) => prev.filter((t) => t !== "history")); - }} - /> - ) : ( - <HistoryDataPanel - featureInfos={chatPanelFeatureInfos ?? (() => { - if (highlightFeatures.length === 0 || !showHistoryPanel) - return []; - - return highlightFeatures - .map((feature) => { - const properties = feature.getProperties(); - const id = properties.id; - if (!id) return null; - - // 从图层名称推断类型 - const layerId = - feature.getId()?.toString().split(".")[0] || ""; - let type = "unknown"; - - if (layerId.includes("pipe")) { - type = "pipe"; - } else if (layerId.includes("junction")) { - type = "junction"; - } else if (layerId.includes("tank")) { - type = "tank"; - } else if (layerId.includes("reservoir")) { - type = "reservoir"; - } else if (layerId.includes("pump")) { - type = "pump"; - } else if (layerId.includes("valve")) { - type = "valve"; - } - // 仅处理 type 为 pipe 或 junction 的情况 - if (type !== "pipe" && type !== "junction") { - return null; - } - return [id, type]; - }) - .filter(Boolean) as [string, string][]; - })()} - scheme_type="burst_analysis" - scheme_name={schemeName} - type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")} - start_time={chatPanelTimeRange?.startTime} - end_time={chatPanelTimeRange?.endTime} - onClose={() => { - deactivateTool("history"); - setActiveTools((prev) => prev.filter((t) => t !== "history")); - }} - /> - ))} + <ToolbarHistoryPanel + showHistoryPanel={showHistoryPanel} + chatPanelType={chatPanelType} + chatPanelFeatureInfos={chatPanelFeatureInfos} + chatPanelTimeRange={chatPanelTimeRange} + highlightFeatures={highlightFeatures} + HistoryPanel={HistoryPanel} + schemeName={schemeName} + queryType={queryType} + onClose={() => { + deactivateTool("history"); + setActiveTools((prev) => prev.filter((t) => t !== "history")); + }} + /> {/* 图例显示 */} {activeLegendConfigs.length > 0 && ( diff --git a/src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx b/src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx new file mode 100644 index 0000000..552aad4 --- /dev/null +++ b/src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React, { useMemo } from "react"; +import Feature from "ol/Feature"; + +import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel"; +import { inferHistoryFeatureInfos } from "./toolbarFeatureHelpers"; +import HistoryDataPanel from "./HistoryDataPanel"; + +type ToolbarHistoryPanelProps = { + showHistoryPanel: boolean; + chatPanelType: "realtime" | "scheme" | "none"; + chatPanelFeatureInfos: [string, string][] | null; + chatPanelTimeRange: { + startTime?: string; + endTime?: string; + } | null; + highlightFeatures: Feature[]; + HistoryPanel?: React.FC<any>; + schemeName?: string; + queryType?: string; + onClose: () => void; +}; + +const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({ + showHistoryPanel, + chatPanelType, + chatPanelFeatureInfos, + chatPanelTimeRange, + highlightFeatures, + HistoryPanel, + schemeName, + queryType, + onClose, +}) => { + const featureInfos = useMemo( + () => chatPanelFeatureInfos ?? inferHistoryFeatureInfos(highlightFeatures), + [chatPanelFeatureInfos, highlightFeatures], + ); + + if (!showHistoryPanel) { + return null; + } + + if (chatPanelType === "none" && chatPanelFeatureInfos) { + return ( + <SCADADataPanel + deviceIds={chatPanelFeatureInfos.map(([id]) => id)} + visible={showHistoryPanel} + start_time={chatPanelTimeRange?.startTime} + end_time={chatPanelTimeRange?.endTime} + onClose={onClose} + /> + ); + } + + if (HistoryPanel) { + return ( + <HistoryPanel + featureInfos={featureInfos} + scheme_type="burst_analysis" + scheme_name={schemeName} + type={ + chatPanelFeatureInfos + ? chatPanelType + : (queryType as "realtime" | "scheme" | "none") + } + start_time={chatPanelTimeRange?.startTime} + end_time={chatPanelTimeRange?.endTime} + onClose={onClose} + /> + ); + } + + return ( + <HistoryDataPanel + featureInfos={featureInfos} + scheme_type="burst_analysis" + scheme_name={schemeName} + type={ + chatPanelFeatureInfos + ? chatPanelType + : (queryType as "realtime" | "scheme" | "none") + } + start_time={chatPanelTimeRange?.startTime} + end_time={chatPanelTimeRange?.endTime} + onClose={onClose} + /> + ); +}; + +export default ToolbarHistoryPanel; diff --git a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts new file mode 100644 index 0000000..dc1019b --- /dev/null +++ b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts @@ -0,0 +1,350 @@ +import Feature from "ol/Feature"; + +import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; + +type ToolbarBaseProperty = { + label: string; + value: string | number; + unit?: string; + formatter?: (value: string | number) => string; +}; + +type ToolbarTableProperty = { + type: "table"; + label: string; + columns: string[]; + rows: (string | number)[][]; +}; + +export type ToolbarPropertyItem = ToolbarBaseProperty | ToolbarTableProperty; + +export type ToolbarPropertyPanelData = { + id?: string; + type?: string; + properties?: ToolbarPropertyItem[]; +}; + +const getFeatureHistoryType = (feature: Feature): string | null => { + const layerId = feature.getId()?.toString().split(".")[0] || ""; + if (layerId.includes("pipe")) return "pipe"; + if (layerId.includes("junction")) return "junction"; + if (layerId.includes("tank")) return "tank"; + if (layerId.includes("reservoir")) return "reservoir"; + if (layerId.includes("pump")) return "pump"; + if (layerId.includes("valve")) return "valve"; + return null; +}; + +export const inferHistoryFeatureInfos = ( + highlightFeatures: Feature[], +): [string, string][] => + highlightFeatures + .map((feature) => { + const properties = feature.getProperties(); + const id = properties.id; + if (!id) return null; + + const type = getFeatureHistoryType(feature); + if (type !== "pipe" && type !== "junction") { + return null; + } + + return [id, type] as [string, string]; + }) + .filter(Boolean) as [string, string][]; + +export const buildFeatureProperties = ( + highlightFeature: Feature | undefined, + computedProperties: Record<string, any>, +): ToolbarPropertyPanelData => { + if (!highlightFeature) return {}; + + const layer = highlightFeature.getId()?.toString().split(".")[0]; + const properties = highlightFeature.getProperties(); + const pipeComputedFields = [ + { key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` }, + { key: "friction", label: "摩阻", unit: "" }, + { key: "headloss", label: "水头损失", unit: "m" }, + { key: "unit_headloss", label: "单位水头损失", unit: "m/km" }, + { key: "quality", label: "水质", unit: "mg/L" }, + { key: "reaction", label: "反应", unit: "1/d" }, + { key: "setting", label: "设置", unit: "" }, + { key: "status", label: "状态", unit: "" }, + { key: "velocity", label: "流速", unit: "m/s" }, + ]; + const nodeComputedFields = [ + { key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` }, + { key: "total_head", label: "水头", unit: "m" }, + { key: "pressure", label: "压力", unit: "m" }, + { key: "quality", label: "水质", unit: "mg/L" }, + ]; + + if (layer === "geo_pipes_mat" || layer === "geo_pipes") { + const result: ToolbarPropertyPanelData = { + id: properties.id, + type: "管道", + properties: [ + { label: "起始节点ID", value: properties.node1 }, + { label: "终点节点ID", value: properties.node2 }, + { label: "长度", value: properties.length?.toFixed?.(1), unit: "m" }, + { + label: "管径", + value: properties.diameter?.toFixed?.(1), + unit: "mm", + }, + { label: "粗糙度", value: properties.roughness }, + { label: "局部损失", value: properties.minor_loss }, + { label: "初始状态", value: "开" }, + ], + }; + + pipeComputedFields.forEach(({ key, label, unit }) => { + let value = computedProperties[key]; + + if (key === "flow" && value !== undefined) { + value = toM3h(value, "lps"); + } + + if ( + key === "unit_headloss" && + value === undefined && + computedProperties.headloss !== undefined && + properties.length + ) { + value = (computedProperties.headloss / properties.length) * 1000; + } + + if (value !== undefined) { + result.properties?.push({ + label, + value: typeof value === "number" ? value.toFixed(3) : value, + unit, + }); + } + }); + + return result; + } + + if (layer === "geo_junctions_mat" || layer === "geo_junctions") { + const result: ToolbarPropertyPanelData = { + id: properties.id, + type: "节点", + properties: [ + { + label: "高程", + value: properties.elevation?.toFixed?.(1), + unit: "m", + }, + { + type: "table", + label: "基本需水量", + columns: ["demand", "pattern"], + rows: Array.from({ length: 5 }, (_, i) => i + 1) + .map((idx) => { + let demand = properties?.[`demand${idx}`]; + const pattern = properties?.[`pattern${idx}`]; + if ( + demand !== undefined && + demand !== null && + demand !== "" + ) { + demand = toM3h(Number(demand), "lps"); + return [ + typeof demand === "number" ? demand.toFixed(3) : demand, + pattern ?? "-", + ]; + } + return null; + }) + .filter(Boolean) as (string | number)[][], + }, + ], + }; + + nodeComputedFields.forEach(({ key, label, unit }) => { + if (computedProperties[key] !== undefined) { + let value = computedProperties[key]; + if (key === "actual_demand") { + value = toM3h(value, "lps"); + } + result.properties?.push({ + label, + value: value?.toFixed?.(3) || value, + unit, + }); + } + }); + + return result; + } + + if (layer === "geo_tanks_mat" || layer === "geo_tanks") { + return { + id: properties.id, + type: "水池", + properties: [ + { + label: "高程", + value: properties.elevation?.toFixed?.(1), + unit: "m", + }, + { + label: "初始水位", + value: properties.init_level?.toFixed?.(1), + unit: "m", + }, + { + label: "最低水位", + value: properties.min_level?.toFixed?.(1), + unit: "m", + }, + { + label: "最高水位", + value: properties.max_level?.toFixed?.(1), + unit: "m", + }, + { + label: "直径", + value: properties.diameter?.toFixed?.(1), + unit: "m", + }, + { + label: "最小容积", + value: properties.min_vol?.toFixed?.(1), + unit: "m³", + }, + { + label: "溢出", + value: properties.overflow ? "是" : "否", + }, + ], + }; + } + + if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") { + return { + id: properties.id, + type: "水库", + properties: [ + { + label: "水头", + value: properties.head?.toFixed?.(1), + unit: "m", + }, + ], + }; + } + + if (layer === "geo_pumps_mat" || layer === "geo_pumps") { + return { + id: properties.id, + type: "水泵", + properties: [ + { label: "起始节点 ID", value: properties.node1 }, + { label: "终点节点 ID", value: properties.node2 }, + { + label: "功率", + value: properties.power?.toFixed?.(1), + unit: "kW", + }, + { + label: "扬程", + value: properties.head?.toFixed?.(1), + unit: "m", + }, + { + label: "转速", + value: properties.speed?.toFixed?.(1), + unit: "rpm", + }, + { + label: "模式", + value: properties.pattern, + }, + ], + }; + } + + if (layer === "geo_valves_mat" || layer === "geo_valves") { + return { + id: properties.id, + type: "阀门", + properties: [ + { label: "起始节点 ID", value: properties.node1 }, + { label: "终点节点 ID", value: properties.node2 }, + { + label: "直径", + value: properties.diameter?.toFixed?.(1), + unit: "mm", + }, + { + label: "阀门类型", + value: properties.v_type, + }, + { + label: "局部损失", + value: properties.minor_loss?.toFixed?.(2), + }, + ], + }; + } + + const getTransmissionFrequency = (transmissionFrequency: string) => { + const parts = transmissionFrequency.split(":"); + if (parts.length !== 3) return transmissionFrequency; + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const seconds = parseInt(parts[2], 10); + return hours * 60 + minutes + (seconds >= 30 ? 1 : 0); + }; + + const getReliability = (reliability: number) => { + switch (reliability) { + case 1: + return "高"; + case 2: + return "中"; + case 3: + return "低"; + default: + return "未知"; + } + }; + + if (layer === "geo_scada_mat" || layer === "geo_scada") { + return { + id: properties.id, + type: "SCADA设备", + properties: [ + { + label: "类型", + value: + properties.type === "pipe_flow" ? "流量传感器" : "压力传感器", + }, + { + label: "关联节点 ID", + value: properties.associated_element_id, + }, + { + label: "传输模式", + value: + properties.transmission_mode === "non_realtime" + ? "定时传输" + : "实时传输", + }, + { + label: "传输频率", + value: getTransmissionFrequency(properties.transmission_frequency), + unit: "分钟", + }, + { + label: "可靠性", + value: getReliability(properties.reliability), + }, + ], + }; + } + + return {}; +}; diff --git a/src/components/olmap/core/Controls/useToolbarChatActions.ts b/src/components/olmap/core/Controls/useToolbarChatActions.ts new file mode 100644 index 0000000..b606d01 --- /dev/null +++ b/src/components/olmap/core/Controls/useToolbarChatActions.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react"; +import Feature from "ol/Feature"; +import { GeoJSON } from "ol/format"; +import Point from "ol/geom/Point"; +import { bbox, featureCollection } from "@turf/turf"; + +import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler"; +import { applyJunctionAreaRender } from "@components/olmap/DMALeakDetection/applyJunctionAreaRender"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { useMap } from "../MapComponent"; + +type UseToolbarChatActionsParams = { + setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>; + setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>; + setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>; + setChatPanelTimeRange: Dispatch< + SetStateAction<{ startTime?: string; endTime?: string } | null> + >; + setShowHistoryPanel: Dispatch<SetStateAction<boolean>>; + setActiveTools: Dispatch<SetStateAction<string[]>>; +}; + +export const useToolbarChatActions = ({ + setHighlightFeatures, + setChatPanelFeatureInfos, + setChatPanelType, + setChatPanelTimeRange, + setShowHistoryPanel, + setActiveTools, +}: UseToolbarChatActionsParams) => { + const map = useMap(); + const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null); + + const disposeChatJunctionRender = useCallback(() => { + chatJunctionRenderCleanupRef.current?.(); + chatJunctionRenderCleanupRef.current = null; + }, []); + + useEffect(() => () => disposeChatJunctionRender(), [disposeChatJunctionRender]); + + useChatToolActionHandler( + useCallback( + (action) => { + const geojsonFormat = new GeoJSON(); + const zoomToFeatures = ( + features: Feature[], + geometryKind: "point" | "line", + ) => { + if (features.length === 0) return; + + if (geometryKind === "point" && features.length === 1) { + const geometry = features[0].getGeometry(); + if (geometry instanceof Point) { + map?.getView().animate({ + center: geometry.getCoordinates(), + zoom: 18, + duration: 1000, + }); + return; + } + } + + const geojsonFeatures = features.map((feature) => + geojsonFormat.writeFeatureObject(feature), + ); + const extent = bbox(featureCollection(geojsonFeatures as any)); + if (extent) { + map?.getView().fit(extent, { + maxZoom: 18, + duration: 1000, + padding: + geometryKind === "line" + ? [60, 60, 60, 60] + : [40, 40, 40, 40], + }); + } + }; + + const locateFeatures = ( + ids: string[], + layer: string, + geometryKind: "point" | "line", + ) => { + queryFeaturesByIds(ids, layer).then((features) => { + if (features.length > 0) { + setHighlightFeatures(features); + zoomToFeatures(features, geometryKind); + } + }); + }; + + switch (action.type) { + case "locate_features": { + locateFeatures(action.ids, action.layer, action.geometryKind); + break; + } + case "view_history": { + setChatPanelFeatureInfos(action.featureInfos); + setChatPanelType(action.dataType); + setChatPanelTimeRange({ + startTime: action.startTime, + endTime: action.endTime, + }); + setShowHistoryPanel(true); + break; + } + case "view_scada": { + setChatPanelFeatureInfos(action.featureInfos); + setChatPanelType("none"); + setChatPanelTimeRange({ + startTime: action.startTime, + endTime: action.endTime, + }); + setShowHistoryPanel(true); + setActiveTools((prev) => { + if (prev.includes("history")) { + return prev; + } + return [...prev, "history"]; + }); + break; + } + case "render_junctions": { + disposeChatJunctionRender(); + + if (Object.keys(action.nodeAreaMap).length === 0) { + break; + } + + if (map) { + chatJunctionRenderCleanupRef.current = applyJunctionAreaRender( + map, + { + nodeAreaMap: action.nodeAreaMap, + areaIds: action.areaIds, + areaColors: action.areaColors, + }, + { propertyKey: "chat_junction_render_index" }, + ); + } + break; + } + } + }, + [ + disposeChatJunctionRender, + map, + setActiveTools, + setChatPanelFeatureInfos, + setChatPanelTimeRange, + setChatPanelType, + setHighlightFeatures, + setShowHistoryPanel, + ], + ), + ); +}; -- 2.54.0 From e4424b87d1c83af06cf13c906ed6206e8cab46df Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 18 May 2026 16:15:38 +0800 Subject: [PATCH 135/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=20ref=20=E6=96=87=E4=BB=B6=E6=B8=B2=E6=9F=93=E5=A4=A7=E9=87=8F?= =?UTF-8?q?=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/ChatToolCallBlock.tsx | 33 ++----- src/components/chat/GlobalChatbox.tsx | 37 +++++++- .../chat/hooks/useAgentToolActions.ts | 49 ++++------ .../core/Controls/useToolbarChatActions.ts | 92 ++++++++++++++++--- src/store/chatToolStore.ts | 5 +- 5 files changed, 140 insertions(+), 76 deletions(-) diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index d396a76..a7f2c79 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -268,12 +268,7 @@ function getToolDescription(toolCall: ToolCall): string { return (params.title as string | undefined) ?? "数据图表"; } case "render_junctions": { - const nodeAreaMap = - params.node_area_map && typeof params.node_area_map === "object" - ? (params.node_area_map as Record<string, unknown>) - : {}; - const areaIds = Array.isArray(params.area_ids) ? params.area_ids : []; - return `${Object.keys(nodeAreaMap).length} 个节点 · ${areaIds.length || new Set(Object.values(nodeAreaMap).map(String)).size} 个分区`; + return (params.render_ref as string | undefined) ?? "渲染引用"; } default: return ""; @@ -398,28 +393,14 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { yAxisName: params.y_axis_name as string | undefined, }; case "render_junctions": { - const nodeAreaMap = - params.node_area_map && typeof params.node_area_map === "object" - ? Object.fromEntries( - Object.entries(params.node_area_map as Record<string, unknown>) - .map(([key, value]) => [String(key), String(value ?? "")]) - .filter(([, value]) => value.trim().length > 0), - ) - : {}; + const renderRef = + typeof params.render_ref === "string" ? params.render_ref.trim() : ""; + if (!renderRef) { + return null; + } return { type: "render_junctions", - nodeAreaMap, - areaIds: Array.isArray(params.area_ids) - ? params.area_ids.map((item) => String(item).trim()).filter(Boolean) - : [], - areaColors: - params.area_colors && typeof params.area_colors === "object" - ? Object.fromEntries( - Object.entries(params.area_colors as Record<string, unknown>) - .map(([key, value]) => [String(key), String(value ?? "")]) - .filter(([, value]) => value.trim().length > 0), - ) - : {}, + renderRef, }; } default: diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index f6985fe..45cc983 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -1,7 +1,13 @@ "use client"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Box, Drawer, alpha, useTheme } from "@mui/material"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { Box, Drawer, alpha, useMediaQuery, useTheme } from "@mui/material"; import type { AgentModel } from "@/lib/chatStream"; import { AgentComposer } from "./AgentComposer"; @@ -27,6 +33,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("sm")); const { speechState, @@ -158,6 +165,32 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }; }, [isResizing]); + useLayoutEffect(() => { + const body = document.body; + const html = document.documentElement; + const previousBodyPaddingRight = body.style.paddingRight; + const previousBodyTransition = body.style.transition; + const previousBodyBoxSizing = body.style.boxSizing; + const previousHtmlBoxSizing = html.style.boxSizing; + const reservedWidth = open && isDesktop ? `${width}px` : "0px"; + + body.style.boxSizing = "border-box"; + html.style.boxSizing = "border-box"; + body.style.paddingRight = reservedWidth; + body.style.transition = isResizing + ? previousBodyTransition + : [previousBodyTransition, "padding-right 240ms cubic-bezier(0.2, 0.8, 0.2, 1)"] + .filter(Boolean) + .join(", "); + + return () => { + body.style.paddingRight = previousBodyPaddingRight; + body.style.transition = previousBodyTransition; + body.style.boxSizing = previousBodyBoxSizing; + html.style.boxSizing = previousHtmlBoxSizing; + }; + }, [isDesktop, isResizing, open, width]); + return ( <Drawer anchor="right" diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts index 6017bb1..e8bd878 100644 --- a/src/components/chat/hooks/useAgentToolActions.ts +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -136,26 +136,6 @@ const resolveTimeRange = (params: Record<string, unknown>) => ({ (params.end as string | undefined), }); -const resolveStringRecord = (value: unknown): Record<string, string> => { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } - - return Object.fromEntries( - Object.entries(value as Record<string, unknown>) - .map(([key, recordValue]) => [String(key), String(recordValue ?? "")]) - .filter(([, recordValue]) => recordValue.trim().length > 0), - ); -}; - -const resolveStringArray = (value: unknown): string[] => { - if (!Array.isArray(value)) { - return []; - } - - return value.map((item) => String(item).trim()).filter(Boolean); -}; - const compactNames = (names: string[]) => { if (!names.length) return ""; return names.length > 3 @@ -251,20 +231,20 @@ const buildToolAction = ( } if (tool === "render_junctions") { - const nodeAreaMap = resolveStringRecord(params.node_area_map); - const areaIds = resolveStringArray(params.area_ids); - const areaColors = resolveStringRecord(params.area_colors); + const renderRef = + typeof params.render_ref === "string" ? params.render_ref.trim() : ""; return { - action: { - type: "render_junctions", - nodeAreaMap, - areaIds, - areaColors, - }, + action: renderRef + ? { + type: "render_junctions", + renderRef, + sessionId: undefined, + } + : null, kind: "map", title: "渲染节点分区", - description: `${Object.keys(nodeAreaMap).length} 个节点`, + description: renderRef || "渲染引用", }; } @@ -286,6 +266,11 @@ export const useAgentToolActions = () => { event.params, ); + const normalizedAction = + action?.type === "render_junctions" + ? { ...action, sessionId: event.sessionId } + : action; + options.appendArtifact(options.assistantMessageId, { id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, tool: event.tool, @@ -295,8 +280,8 @@ export const useAgentToolActions = () => { params: event.params, }); - if (action) { - dispatchToolAction(action); + if (normalizedAction) { + dispatchToolAction(normalizedAction); } }, [dispatchToolAction], diff --git a/src/components/olmap/core/Controls/useToolbarChatActions.ts b/src/components/olmap/core/Controls/useToolbarChatActions.ts index b606d01..bf9f3a9 100644 --- a/src/components/olmap/core/Controls/useToolbarChatActions.ts +++ b/src/components/olmap/core/Controls/useToolbarChatActions.ts @@ -5,8 +5,13 @@ import Point from "ol/geom/Point"; import { bbox, featureCollection } from "@turf/turf"; import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler"; -import { applyJunctionAreaRender } from "@components/olmap/DMALeakDetection/applyJunctionAreaRender"; +import { + applyJunctionAreaRender, + type JunctionAreaRenderPayload, +} from "@components/olmap/DMALeakDetection/applyJunctionAreaRender"; +import { apiFetch } from "@/lib/apiFetch"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { config } from "@/config/config"; import { useMap } from "../MapComponent"; type UseToolbarChatActionsParams = { @@ -30,6 +35,7 @@ export const useToolbarChatActions = ({ }: UseToolbarChatActionsParams) => { const map = useMap(); const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null); + const renderRequestSeqRef = useRef(0); const disposeChatJunctionRender = useCallback(() => { chatJunctionRenderCleanupRef.current?.(); @@ -122,22 +128,82 @@ export const useToolbarChatActions = ({ } case "render_junctions": { disposeChatJunctionRender(); + renderRequestSeqRef.current += 1; + const requestSeq = renderRequestSeqRef.current; - if (Object.keys(action.nodeAreaMap).length === 0) { + if (!action.renderRef || !map) { break; } - if (map) { - chatJunctionRenderCleanupRef.current = applyJunctionAreaRender( - map, - { - nodeAreaMap: action.nodeAreaMap, - areaIds: action.areaIds, - areaColors: action.areaColors, - }, - { propertyKey: "chat_junction_render_index" }, - ); - } + void (async () => { + try { + const query = action.sessionId + ? `?session_id=${encodeURIComponent(action.sessionId)}` + : ""; + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/render-ref/${encodeURIComponent(action.renderRef)}${query}`, + { + method: "GET", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + + if (!response.ok) { + throw new Error(`render ref request failed: ${response.status}`); + } + + const payload = (await response.json()) as { + data?: { + node_area_map?: Record<string, unknown>; + area_ids?: unknown[]; + area_colors?: Record<string, unknown>; + }; + }; + + const data = payload.data; + if (!data?.node_area_map) { + throw new Error("render ref payload missing node_area_map"); + } + + const renderPayload: JunctionAreaRenderPayload = { + nodeAreaMap: Object.fromEntries( + Object.entries(data.node_area_map).map(([key, value]) => [ + String(key), + String(value ?? ""), + ]), + ), + areaIds: Array.isArray(data.area_ids) + ? data.area_ids.map((item) => String(item).trim()).filter(Boolean) + : [], + areaColors: + data.area_colors && typeof data.area_colors === "object" + ? Object.fromEntries( + Object.entries(data.area_colors).map(([key, value]) => [ + String(key), + String(value ?? ""), + ]), + ) + : {}, + }; + + if ( + requestSeq !== renderRequestSeqRef.current || + Object.keys(renderPayload.nodeAreaMap).length === 0 + ) { + return; + } + + chatJunctionRenderCleanupRef.current = applyJunctionAreaRender( + map, + renderPayload, + { propertyKey: "chat_junction_render_index" }, + ); + } catch (error) { + console.error("Failed to resolve render_ref for junction render:", error); + } + })(); break; } } diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index 5d44ecf..317ffa4 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -37,9 +37,8 @@ export type ChatToolAction = } | { type: "render_junctions"; - nodeAreaMap: Record<string, string>; - areaIds?: string[]; - areaColors?: Record<string, string>; + renderRef: string; + sessionId?: string; }; interface ChatToolState { -- 2.54.0 From 3800d73e85f4c1d95da4b01f390fcff154f36edb Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 18 May 2026 18:06:12 +0800 Subject: [PATCH 136/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Agent=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A1=86=E8=A6=86=E7=9B=96=E5=9C=B0=E5=9B=BE=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 36 +++++---------------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 45cc983..921ab5b 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -3,11 +3,10 @@ import React, { useCallback, useEffect, - useLayoutEffect, useRef, useState, } from "react"; -import { Box, Drawer, alpha, useMediaQuery, useTheme } from "@mui/material"; +import { Box, Drawer, alpha, useTheme } from "@mui/material"; import type { AgentModel } from "@/lib/chatStream"; import { AgentComposer } from "./AgentComposer"; @@ -33,7 +32,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); const theme = useTheme(); - const isDesktop = useMediaQuery(theme.breakpoints.up("sm")); const { speechState, @@ -165,32 +163,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }; }, [isResizing]); - useLayoutEffect(() => { - const body = document.body; - const html = document.documentElement; - const previousBodyPaddingRight = body.style.paddingRight; - const previousBodyTransition = body.style.transition; - const previousBodyBoxSizing = body.style.boxSizing; - const previousHtmlBoxSizing = html.style.boxSizing; - const reservedWidth = open && isDesktop ? `${width}px` : "0px"; - - body.style.boxSizing = "border-box"; - html.style.boxSizing = "border-box"; - body.style.paddingRight = reservedWidth; - body.style.transition = isResizing - ? previousBodyTransition - : [previousBodyTransition, "padding-right 240ms cubic-bezier(0.2, 0.8, 0.2, 1)"] - .filter(Boolean) - .join(", "); - - return () => { - body.style.paddingRight = previousBodyPaddingRight; - body.style.transition = previousBodyTransition; - body.style.boxSizing = previousBodyBoxSizing; - html.style.boxSizing = previousHtmlBoxSizing; - }; - }, [isDesktop, isResizing, open, width]); - return ( <Drawer anchor="right" @@ -200,7 +172,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { hideBackdrop disableScrollLock disableEnforceFocus - sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }} + sx={{ + zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, + pointerEvents: "none", + }} PaperProps={{ sx: { width: { xs: "100%", sm: width }, @@ -208,6 +183,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { boxShadow: "none", overflow: open ? "visible" : "hidden", zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, + pointerEvents: "auto", transition: isResizing ? "none" : undefined, }, }} -- 2.54.0 From 9106b8d4a90464ddb16f76bcbf22cc7ed3bcae58 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 19 May 2026 16:42:28 +0800 Subject: [PATCH 137/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E9=87=8D=E5=91=BD=E5=90=8D=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8E=86=E5=8F=B2=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentHeader.test.tsx | 40 +++ src/components/chat/AgentHeader.tsx | 194 +++++++++++++-- .../chat/AgentHistoryPanel.test.tsx | 40 +++ src/components/chat/AgentHistoryPanel.tsx | 228 ++++++++++++++---- src/components/chat/GlobalChatbox.tsx | 20 ++ src/components/chat/GlobalChatbox.types.ts | 2 + src/components/chat/chatStorage.ts | 10 + .../chat/hooks/useAgentChatSession.ts | 45 +++- 8 files changed, 508 insertions(+), 71 deletions(-) create mode 100644 src/components/chat/AgentHeader.test.tsx create mode 100644 src/components/chat/AgentHistoryPanel.test.tsx diff --git a/src/components/chat/AgentHeader.test.tsx b/src/components/chat/AgentHeader.test.tsx new file mode 100644 index 0000000..53929c6 --- /dev/null +++ b/src/components/chat/AgentHeader.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; + +import { AgentHeader } from "./AgentHeader"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ComponentProps<"img">) => <img {...props} alt={props.alt ?? ""} />, +})); + +const renderWithTheme = (ui: React.ReactElement) => + render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>); + +describe("AgentHeader", () => { + it("submits a renamed active session title", () => { + const onRenameSessionTitle = jest.fn(); + + renderWithTheme( + <AgentHeader + sessionTitle="原始标题" + canRenameSessionTitle + isStreaming={false} + isHistoryOpen={false} + onHistoryToggle={jest.fn()} + onRenameSessionTitle={onRenameSessionTitle} + onNewConversation={jest.fn()} + onClose={jest.fn()} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "修改对话标题" })); + fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), { + target: { value: "更新后的标题" }, + }); + fireEvent.click(screen.getByLabelText("确认修改对话标题")); + + expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题"); + }); +}); diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index be57972..2573158 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -8,34 +8,69 @@ import { Box, IconButton, Stack, + TextField, Tooltip, Typography, alpha, useTheme, } from "@mui/material"; -import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; +import CheckRounded from "@mui/icons-material/CheckRounded"; import CloseRounded from "@mui/icons-material/CloseRounded"; +import EditRounded from "@mui/icons-material/EditRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import HistoryRounded from "@mui/icons-material/HistoryRounded"; type AgentHeaderProps = { sessionTitle?: string; + canRenameSessionTitle?: boolean; + isHydrating?: boolean; isStreaming: boolean; isHistoryOpen: boolean; onHistoryToggle: () => void; + onRenameSessionTitle?: (title: string) => void; onNewConversation: () => void; onClose: () => void; }; export const AgentHeader = ({ sessionTitle, + canRenameSessionTitle = false, + isHydrating = false, isStreaming, isHistoryOpen, onHistoryToggle, + onRenameSessionTitle, onNewConversation, onClose, }: AgentHeaderProps) => { const theme = useTheme(); const displayTitle = sessionTitle?.trim() || "TJWater Agent"; + const [isEditingTitle, setIsEditingTitle] = React.useState(false); + const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); + + React.useEffect(() => { + if (!isEditingTitle) { + setDraftTitle(sessionTitle?.trim() || ""); + } + }, [isEditingTitle, sessionTitle]); + + const handleStartEditing = () => { + if (!canRenameSessionTitle || isHydrating || isStreaming) return; + setDraftTitle(sessionTitle?.trim() || ""); + setIsEditingTitle(true); + }; + + const handleCancelEditing = () => { + setDraftTitle(sessionTitle?.trim() || ""); + setIsEditingTitle(false); + }; + + const handleConfirmEditing = () => { + const normalizedTitle = draftTitle.trim(); + if (!normalizedTitle) return; + onRenameSessionTitle?.(normalizedTitle); + setIsEditingTitle(false); + }; return ( <Box @@ -89,35 +124,142 @@ export const AgentHeader = ({ "0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` }, "70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` }, "100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` }, - } + }, }} /> </Box> </motion.div> - <Box sx={{ minWidth: 0 }}> - <Typography - variant="h6" - fontWeight={800} - sx={{ - background: `linear-gradient(90deg, #01579b, #00838f)`, - backgroundClip: "text", - color: "transparent", - letterSpacing: -0.3, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - maxWidth: { xs: "calc(100vw - 220px)", sm: 320 }, - }} - > - {displayTitle} - </Typography> - <Typography variant="caption" color="text.secondary" fontWeight={500}> - {isStreaming - ? "正在思考分析任务..." - : displayTitle === "TJWater Agent" - ? "基于大模型的水力分析引擎" - : "当前会话标题"} - </Typography> + <Box sx={{ minWidth: 0, minHeight: 52, display: "flex", flexDirection: "column", justifyContent: "center" }}> + {isEditingTitle ? ( + <Box> + <Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 }, transform: "translateY(2px)" }}> + <TextField + value={draftTitle} + onChange={(event) => setDraftTitle(event.target.value)} + size="small" + autoFocus + placeholder="请输入对话标题" + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleConfirmEditing(); + } else if (event.key === "Escape") { + event.preventDefault(); + handleCancelEditing(); + } + }} + sx={{ + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-root": { + height: 34, + bgcolor: alpha("#fff", 0.7), + borderRadius: 1.5, + transition: "all 0.2s ease-in-out", + "& fieldset": { + borderColor: alpha("#000", 0.08), + }, + "&:hover fieldset": { + borderColor: alpha(theme.palette.primary.main, 0.4), + }, + "&.Mui-focused fieldset": { + borderColor: theme.palette.primary.main, + borderWidth: "1.5px", + boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`, + }, + }, + "& .MuiInputBase-input": { + padding: "4px 12px", + fontSize: "1.05rem", + fontWeight: 700, + color: theme.palette.text.primary, + } + }} + /> + <IconButton + size="small" + aria-label="确认" + onClick={handleConfirmEditing} + disabled={!draftTitle.trim()} + sx={{ + width: 30, + height: 30, + color: "success.main", + bgcolor: alpha(theme.palette.success.main, 0.1), + "&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) }, + }} + > + <CheckRounded sx={{ fontSize: 18 }} /> + </IconButton> + <IconButton + size="small" + aria-label="取消" + onClick={handleCancelEditing} + sx={{ + width: 30, + height: 30, + color: "text.secondary", + bgcolor: alpha("#000", 0.05), + "&:hover": { bgcolor: alpha("#000", 0.1) }, + }} + > + <CloseRounded sx={{ fontSize: 18 }} /> + </IconButton> + </Stack> + </Box> + ) : ( + <> + <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}> + <Typography + variant="h6" + fontWeight={800} + sx={{ + background: `linear-gradient(90deg, #01579b, #00838f)`, + backgroundClip: "text", + color: "transparent", + letterSpacing: -0.3, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: { xs: "calc(100vw - 256px)", sm: 284 }, + }} + > + {displayTitle} + </Typography> + {canRenameSessionTitle ? ( + <Tooltip title="修改对话标题"> + <span> + <IconButton + size="small" + aria-label="修改对话标题" + onClick={handleStartEditing} + disabled={isHydrating || isStreaming} + sx={{ + width: 24, + height: 24, + color: "text.secondary", + bgcolor: alpha("#fff", 0.45), + "&:hover": { + color: "primary.main", + bgcolor: alpha(theme.palette.primary.main, 0.08), + }, + }} + > + <EditRounded sx={{ fontSize: 16 }} /> + </IconButton> + </span> + </Tooltip> + ) : null} + </Stack> + <Typography variant="caption" color="text.secondary" fontWeight={500}> + {isStreaming + ? "正在思考分析任务..." + : displayTitle === "TJWater Agent" + ? "基于大模型的水力分析引擎" + : "当前会话标题"} + </Typography> + </> + )} </Box> </Stack> diff --git a/src/components/chat/AgentHistoryPanel.test.tsx b/src/components/chat/AgentHistoryPanel.test.tsx new file mode 100644 index 0000000..8dc3d6d --- /dev/null +++ b/src/components/chat/AgentHistoryPanel.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; + +import { AgentHistoryPanel } from "./AgentHistoryPanel"; + +const renderWithTheme = (ui: React.ReactElement) => + render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>); + +describe("AgentHistoryPanel", () => { + it("renames a history session from the list", () => { + const onRenameSession = jest.fn(); + + renderWithTheme( + <AgentHistoryPanel + sessions={[ + { + id: "session-1", + title: "旧会话标题", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ]} + activeSessionId="session-1" + onNewSession={jest.fn()} + onRenameSession={onRenameSession} + onSelectSession={jest.fn()} + onDeleteSession={jest.fn()} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "修改会话标题" })); + fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), { + target: { value: "新的会话标题" }, + }); + fireEvent.click(screen.getByLabelText("确认修改历史会话标题")); + + expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题"); + }); +}); diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index 21b8dde..ca606c4 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -18,7 +18,11 @@ import { Tooltip, Typography, alpha, + useTheme, } from "@mui/material"; +import CheckRounded from "@mui/icons-material/CheckRounded"; +import CloseRounded from "@mui/icons-material/CloseRounded"; +import EditRounded from "@mui/icons-material/EditRounded"; import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded"; import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded"; @@ -31,6 +35,7 @@ type AgentHistoryPanelProps = { activeSessionId?: string; isHydrating?: boolean; onNewSession: () => void; + onRenameSession: (sessionId: string, title: string) => void; onSelectSession: (sessionId: string) => void; onDeleteSession: (sessionId: string) => void; }; @@ -68,14 +73,19 @@ const getSessionGroupLabel = (timestamp: number) => { }; export const AgentHistoryPanel = ({ + sessions, activeSessionId, isHydrating = false, onNewSession, + onRenameSession, onSelectSession, onDeleteSession, }: AgentHistoryPanelProps) => { + const theme = useTheme(); const [keyword, setKeyword] = React.useState(""); + const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null); + const [draftTitle, setDraftTitle] = React.useState(""); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null); @@ -105,6 +115,23 @@ export const AgentHistoryPanel = ({ (session) => session.id === pendingDeleteSessionId, ); + const handleStartRename = (sessionId: string, title: string) => { + setEditingSessionId(sessionId); + setDraftTitle(title); + }; + + const handleCancelRename = () => { + setEditingSessionId(null); + setDraftTitle(""); + }; + + const handleConfirmRename = (sessionId: string) => { + const normalizedTitle = draftTitle.trim(); + if (!normalizedTitle) return; + onRenameSession(sessionId, normalizedTitle); + handleCancelRename(); + }; + return ( <> <Paper @@ -240,7 +267,10 @@ export const AgentHistoryPanel = ({ <Paper key={session.id} elevation={0} - onClick={() => onSelectSession(session.id)} + onClick={() => { + if (editingSessionId === session.id) return; + onSelectSession(session.id); + }} sx={{ px: 1.25, py: 1, @@ -259,49 +289,163 @@ export const AgentHistoryPanel = ({ > <Stack direction="row" spacing={1} alignItems="flex-start"> <Box sx={{ flex: 1, minWidth: 0 }}> - <Typography - variant="body2" - fontWeight={isActive ? 800 : 700} - color="text.primary" - sx={{ - overflow: "hidden", - textOverflow: "ellipsis", - display: "-webkit-box", - WebkitLineClamp: 2, - WebkitBoxOrient: "vertical", - }} - > - {session.title} - </Typography> - <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}> - {formatRelativeDate(session.updatedAt)} - </Typography> + {editingSessionId === session.id ? ( + <Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}> + <TextField + value={draftTitle} + onChange={(event) => setDraftTitle(event.target.value)} + size="small" + autoFocus + placeholder="请输入会话标题" + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + handleConfirmRename(session.id); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + handleCancelRename(); + } + }} + sx={{ + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-root": { + height: 32, + bgcolor: alpha("#fff", 0.75), + borderRadius: 1.5, + transition: "all 0.2s ease-in-out", + "& fieldset": { + borderColor: alpha("#000", 0.08), + }, + "&:hover fieldset": { + borderColor: alpha(theme.palette.primary.main, 0.4), + }, + "&.Mui-focused fieldset": { + borderColor: theme.palette.primary.main, + borderWidth: "1.5px", + boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`, + }, + }, + "& .MuiInputBase-input": { + padding: "4px 10px", + fontSize: "0.85rem", + fontWeight: 700, + color: theme.palette.text.primary, + } + }} + /> + <IconButton + size="small" + aria-label="确认" + onClick={(event) => { + event.stopPropagation(); + handleConfirmRename(session.id); + }} + disabled={!draftTitle.trim()} + sx={{ + width: 28, + height: 28, + color: "success.main", + bgcolor: alpha(theme.palette.success.main, 0.1), + "&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) }, + }} + > + <CheckRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton + size="small" + aria-label="取消" + onClick={(event) => { + event.stopPropagation(); + handleCancelRename(); + }} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.05), + "&:hover": { bgcolor: alpha("#000", 0.1) }, + }} + > + <CloseRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Stack> + ) : ( + <Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}> + <Typography + variant="body2" + fontWeight={isActive ? 800 : 700} + color="text.primary" + sx={{ + overflow: "hidden", + textOverflow: "ellipsis", + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + }} + > + {session.title} + </Typography> + <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}> + {formatRelativeDate(session.updatedAt)} + </Typography> + </Box> + )} </Box> - <Tooltip title="删除会话"> - <span> - <IconButton - size="small" - aria-label="删除会话" - onClick={(event) => { - event.stopPropagation(); - setPendingDeleteSessionId(session.id); - setIsDeleteDialogOpen(true); - }} - sx={{ - width: 24, - height: 24, - color: "text.secondary", - "&:hover": { - color: "error.main", - bgcolor: alpha("#ef5350", 0.08), - }, - }} - > - <DeleteOutlineRounded sx={{ fontSize: 16 }} /> - </IconButton> - </span> - </Tooltip> + <Stack direction="row" spacing={0.25} sx={{ display: editingSessionId === session.id ? 'none' : 'flex' }}> + <Tooltip title="修改会话标题"> + <span> + <IconButton + size="small" + aria-label="修改会话标题" + onClick={(event) => { + event.stopPropagation(); + handleStartRename(session.id, session.title); + }} + disabled={isHydrating || editingSessionId === session.id} + sx={{ + width: 24, + height: 24, + color: "text.secondary", + "&:hover": { + color: "primary.main", + bgcolor: alpha("#00acc1", 0.08), + }, + }} + > + <EditRounded sx={{ fontSize: 16 }} /> + </IconButton> + </span> + </Tooltip> + <Tooltip title="删除会话"> + <span> + <IconButton + size="small" + aria-label="删除会话" + onClick={(event) => { + event.stopPropagation(); + setPendingDeleteSessionId(session.id); + setIsDeleteDialogOpen(true); + }} + sx={{ + width: 24, + height: 24, + color: "text.secondary", + "&:hover": { + color: "error.main", + bgcolor: alpha("#ef5350", 0.08), + }, + }} + > + <DeleteOutlineRounded sx={{ fontSize: 16 }} /> + </IconButton> + </span> + </Tooltip> + </Stack> </Stack> </Paper> ); diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 921ab5b..8f4db76 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -70,6 +70,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { cycleBranch, abort, createSession, + renameSession, removeSession, switchSession, } = useAgentChatSession({ @@ -134,6 +135,21 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { [removeSession], ); + const handleRenameSession = useCallback( + (storageSessionId: string, title: string) => { + void renameSession(storageSessionId, title); + }, + [renameSession], + ); + + const handleRenameActiveSession = useCallback( + (title: string) => { + if (!activeStorageSessionId) return; + void renameSession(activeStorageSessionId, title); + }, + [activeStorageSessionId, renameSession], + ); + const handleMouseDown = useCallback((event: React.MouseEvent) => { event.preventDefault(); setIsResizing(true); @@ -231,9 +247,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <AgentHeader sessionTitle={sessionTitle} + canRenameSessionTitle={Boolean(activeStorageSessionId)} + isHydrating={isHydrating} isStreaming={isStreaming} isHistoryOpen={isHistoryOpen} onHistoryToggle={handleHistoryToggle} + onRenameSessionTitle={handleRenameActiveSession} onNewConversation={handleNewConversation} onClose={onClose} /> @@ -277,6 +296,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { handleSelectSession(id); setIsHistoryOpen(false); }} + onRenameSession={handleRenameSession} onDeleteSession={handleDeleteSession} /> </Box> diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 4fae4b3..acba4e7 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -75,6 +75,7 @@ export type LegacyPersistedChatState = { export type ChatSessionRecord = { id: string; title: string; + isTitleManuallyEdited?: boolean; createdAt: number; updatedAt: number; sessionId?: string; @@ -98,6 +99,7 @@ export type ChatStorageMeta = { export type LoadedChatState = { storageSessionId?: string; title?: string; + isTitleManuallyEdited?: boolean; messages: Message[]; sessionId?: string; branchGroups: BranchGroup[]; diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 3574362..3c2d794 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -39,6 +39,7 @@ type ChatDB = DBSchema & { const emptyLoadedChatState = (): LoadedChatState => ({ storageSessionId: undefined, title: undefined, + isTitleManuallyEdited: false, messages: [], sessionId: undefined, branchGroups: [], @@ -55,6 +56,7 @@ const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatSt return { storageSessionId: session.id, title: session.title, + isTitleManuallyEdited: session.isTitleManuallyEdited ?? false, messages: sanitizeMessages(session.messages), sessionId: session.sessionId, branchGroups: sanitizeBranchGroups(session.branchGroups), @@ -163,6 +165,7 @@ const migrateLegacyLocalStorage = async () => { const sessionRecord: ChatSessionRecord = { id: createId(), title: "新对话", + isTitleManuallyEdited: false, createdAt: now, updatedAt: now, sessionId: legacyState.sessionId, @@ -241,6 +244,7 @@ export const saveActiveChatState = async ( const nextRecord: ChatSessionRecord = { id: storageSessionId, title: finalTitle, + isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false, createdAt: existingSession?.createdAt ?? now, updatedAt: now, sessionId: state.sessionId, @@ -272,6 +276,9 @@ export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { export const updateChatSessionTitle = async ( storageSessionId: string, title: string, + options?: { + isTitleManuallyEdited?: boolean; + }, ): Promise<void> => { if (typeof window === "undefined") return; @@ -285,6 +292,8 @@ export const updateChatSessionTitle = async ( await db.put(SESSION_STORE, { ...session, title: normalizedTitle, + isTitleManuallyEdited: + options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false, updatedAt: Date.now(), }); }; @@ -298,6 +307,7 @@ export const createEmptyChatSession = async (): Promise<LoadedChatState> => { const session: ChatSessionRecord = { id: createId(), title: "新对话", + isTitleManuallyEdited: false, createdAt: now, updatedAt: now, sessionId: undefined, diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 08e6f97..b517898 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -146,6 +146,7 @@ export const useAgentChatSession = ({ const [messages, setMessages] = useState<Message[]>([]); const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined); + const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false); const [sessionId, setSessionId] = useState<string | undefined>(undefined); const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]); const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); @@ -154,6 +155,7 @@ export const useAgentChatSession = ({ const [isHydrating, setIsHydrating] = useState(true); const abortRef = useRef<AbortController | null>(null); const sessionIdRef = useRef<string | undefined>(undefined); + const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef<Promise<void> | null>(null); const titleUpdateNonceRef = useRef(0); @@ -161,6 +163,10 @@ export const useAgentChatSession = ({ sessionIdRef.current = sessionId; }, [sessionId]); + useEffect(() => { + isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; + }, [isSessionTitleManuallyEdited]); + useEffect(() => { let cancelled = false; @@ -180,6 +186,7 @@ export const useAgentChatSession = ({ setMessages(loadedState.messages); setSessionTitle(loadedState.title); + setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setSessionId(loadedState.sessionId); setBranchGroups(loadedState.branchGroups); setChatSessions(sessions); @@ -207,6 +214,7 @@ export const useAgentChatSession = ({ const state: LoadedChatState = { storageSessionId: storageSessionIdRef.current, title: sessionTitle, + isTitleManuallyEdited: isSessionTitleManuallyEdited, messages, sessionId, branchGroups, @@ -230,7 +238,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, isHydrating, messages, sessionId, sessionTitle]); + }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -354,12 +362,14 @@ export const useAgentChatSession = ({ }); } else if (event.type === "session_title") { const nextTitle = event.title.trim(); - if (nextTitle) { + if (nextTitle && !isSessionTitleManuallyEditedRef.current) { setSessionTitle(nextTitle); const currentStorageSessionId = storageSessionIdRef.current; if (currentStorageSessionId) { const currentNonce = ++titleUpdateNonceRef.current; - void updateChatSessionTitle(currentStorageSessionId, nextTitle) + void updateChatSessionTitle(currentStorageSessionId, nextTitle, { + isTitleManuallyEdited: false, + }) .then(() => listChatSessions()) .then((sessions) => { if (titleUpdateNonceRef.current !== currentNonce) return; @@ -487,6 +497,7 @@ export const useAgentChatSession = ({ } setMessages([]); setSessionTitle(undefined); + setIsSessionTitleManuallyEdited(false); setBranchGroups([]); setBranchTransition(null); setSessionId(undefined); @@ -512,6 +523,7 @@ export const useAgentChatSession = ({ sessionIdRef.current = newState.sessionId; setMessages(newState.messages); setSessionTitle(newState.title); + setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false); setSessionId(newState.sessionId); setBranchGroups(newState.branchGroups); setChatSessions(sessions); @@ -538,6 +550,7 @@ export const useAgentChatSession = ({ setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); + setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); setBranchGroups(nextState.branchGroups); setChatSessions(sessions); @@ -571,6 +584,7 @@ export const useAgentChatSession = ({ setBranchTransition(null); setMessages([]); setSessionTitle(undefined); + setIsSessionTitleManuallyEdited(false); setSessionId(undefined); setBranchGroups([]); return; @@ -588,6 +602,7 @@ export const useAgentChatSession = ({ setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); + setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); setBranchGroups(nextState.branchGroups); setChatSessions(sessionsAfterDelete); @@ -607,6 +622,29 @@ export const useAgentChatSession = ({ [runPrompt], ); + const renameSession = useCallback( + async (targetStorageSessionId: string, nextTitle: string) => { + const normalizedTitle = nextTitle.trim(); + if (!normalizedTitle || isHydrating) return; + + try { + await updateChatSessionTitle(targetStorageSessionId, normalizedTitle, { + isTitleManuallyEdited: true, + }); + const sessions = await listChatSessions(); + setChatSessions(sessions); + + if (storageSessionIdRef.current === targetStorageSessionId) { + setSessionTitle(normalizedTitle); + setIsSessionTitleManuallyEdited(true); + } + } catch (error) { + console.error("[GlobalChatbox] Failed to rename chat session:", error); + } + }, + [isHydrating], + ); + const regenerate = useCallback(async () => { if (isHydrating || isStreaming || messages.length === 0) return; @@ -776,6 +814,7 @@ export const useAgentChatSession = ({ abort, createSession, reset, + renameSession, removeSession, switchSession, }; -- 2.54.0 From 2fbfba118fcedc1f7f719a404fc3e1132caead47 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 19 May 2026 16:48:56 +0800 Subject: [PATCH 138/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=8E=92=E5=BA=8F=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=E9=A6=96=E6=9D=A1=E6=B6=88=E6=81=AF=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/AgentHistoryPanel.test.tsx | 31 ++++++++++++- src/components/chat/AgentHistoryPanel.tsx | 23 +++++++--- src/components/chat/chatStorage.ts | 43 +++++++++++++------ 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/src/components/chat/AgentHistoryPanel.test.tsx b/src/components/chat/AgentHistoryPanel.test.tsx index 8dc3d6d..8dc4227 100644 --- a/src/components/chat/AgentHistoryPanel.test.tsx +++ b/src/components/chat/AgentHistoryPanel.test.tsx @@ -33,8 +33,37 @@ describe("AgentHistoryPanel", () => { fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), { target: { value: "新的会话标题" }, }); - fireEvent.click(screen.getByLabelText("确认修改历史会话标题")); + fireEvent.click(screen.getByLabelText("确认")); expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题"); }); + + it("orders history by the first message time instead of the latest update time", () => { + renderWithTheme( + <AgentHistoryPanel + sessions={[ + { + id: "session-newer-update", + title: "较新的更新", + createdAt: new Date("2026-05-18T09:00:00+08:00").getTime(), + updatedAt: new Date("2026-05-19T12:00:00+08:00").getTime(), + }, + { + id: "session-newer-first-message", + title: "较新的首条消息", + createdAt: new Date("2026-05-19T08:00:00+08:00").getTime(), + updatedAt: new Date("2026-05-19T08:30:00+08:00").getTime(), + }, + ]} + onNewSession={jest.fn()} + onRenameSession={jest.fn()} + onSelectSession={jest.fn()} + onDeleteSession={jest.fn()} + />, + ); + + const sessionTitles = screen.getAllByText(/较新的/).map((element) => element.textContent); + + expect(sessionTitles).toEqual(["较新的首条消息", "较新的更新"]); + }); }); diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index ca606c4..496b816 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -73,7 +73,6 @@ const getSessionGroupLabel = (timestamp: number) => { }; export const AgentHistoryPanel = ({ - sessions, activeSessionId, isHydrating = false, @@ -95,11 +94,25 @@ export const AgentHistoryPanel = ({ return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword)); }, [keyword, sessions]); + const sortedFilteredSessions = React.useMemo( + () => + [...filteredSessions].sort((left, right) => { + const createdAtDiff = right.createdAt - left.createdAt; + if (createdAtDiff !== 0) return createdAtDiff; + + const updatedAtDiff = right.updatedAt - left.updatedAt; + if (updatedAtDiff !== 0) return updatedAtDiff; + + return right.id.localeCompare(left.id); + }), + [filteredSessions], + ); + const groupedSessions = React.useMemo(() => { const groups = new Map<string, ChatSessionSummary[]>(); - filteredSessions.forEach((session) => { - const label = getSessionGroupLabel(session.updatedAt); + sortedFilteredSessions.forEach((session) => { + const label = getSessionGroupLabel(session.createdAt); const existing = groups.get(label); if (existing) { existing.push(session); @@ -109,7 +122,7 @@ export const AgentHistoryPanel = ({ }); return Array.from(groups.entries()); - }, [filteredSessions]); + }, [sortedFilteredSessions]); const pendingDeleteSession = filteredSessions.find( (session) => session.id === pendingDeleteSessionId, @@ -390,7 +403,7 @@ export const AgentHistoryPanel = ({ {session.title} </Typography> <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}> - {formatRelativeDate(session.updatedAt)} + {formatRelativeDate(session.createdAt)} </Typography> </Box> )} diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 3c2d794..51d8a34 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -51,6 +51,28 @@ const sanitizeMessages = (messages: Message[] | undefined) => const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; +const hasChatContent = (state: { + messages: Message[]; + branchGroups: BranchGroup[]; + sessionId?: string; +}) => + state.messages.length > 0 || + state.branchGroups.length > 0 || + Boolean(state.sessionId); + +const compareSessionsByAnchorTime = ( + left: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">, + right: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">, +) => { + const createdAtDiff = right.createdAt - left.createdAt; + if (createdAtDiff !== 0) return createdAtDiff; + + const updatedAtDiff = right.updatedAt - left.updatedAt; + if (updatedAtDiff !== 0) return updatedAtDiff; + + return right.id.localeCompare(left.id); +}; + const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => { if (!session) return emptyLoadedChatState(); return { @@ -131,7 +153,7 @@ const getLatestSession = async () => { const db = await getDb(); const sessions = await db.getAll(SESSION_STORE); if (sessions.length === 0) return undefined; - return sessions.sort((left, right) => right.updatedAt - left.updatedAt)[0]; + return sessions.sort(compareSessionsByAnchorTime)[0]; }; const migrateLegacyLocalStorage = async () => { @@ -215,10 +237,7 @@ export const saveActiveChatState = async ( ): Promise<string | undefined> => { if (typeof window === "undefined") return state.storageSessionId; - const hasContent = - state.messages.length > 0 || - state.branchGroups.length > 0 || - Boolean(state.sessionId); + const hasContent = hasChatContent(state); const db = await getDb(); const existingSession = state.storageSessionId @@ -241,11 +260,15 @@ export const saveActiveChatState = async ( const storageSessionId = state.storageSessionId ?? createId(); const preferredTitle = state.title?.trim(); const finalTitle = preferredTitle || existingSession?.title || "新对话"; + const shouldAnchorCreatedAtToFirstMessage = + Boolean(existingSession) && !hasChatContent(existingSession) && hasContent; const nextRecord: ChatSessionRecord = { id: storageSessionId, title: finalTitle, isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false, - createdAt: existingSession?.createdAt ?? now, + createdAt: shouldAnchorCreatedAtToFirstMessage + ? now + : existingSession?.createdAt ?? now, updatedAt: now, sessionId: state.sessionId, messages: sanitizeMessages(state.messages), @@ -268,9 +291,7 @@ export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { const db = await getDb(); const sessions = await db.getAll(SESSION_STORE); - return sessions - .sort((left, right) => right.updatedAt - left.updatedAt) - .map(toSessionSummary); + return sessions.sort(compareSessionsByAnchorTime).map(toSessionSummary); }; export const updateChatSessionTitle = async ( @@ -353,9 +374,7 @@ export const deleteChatSession = async (sessionId: string): Promise<string | und await db.delete(SESSION_STORE, sessionId); const remainingSessions = await db.getAll(SESSION_STORE); - const nextActiveSession = remainingSessions.sort( - (left, right) => right.updatedAt - left.updatedAt, - )[0]; + const nextActiveSession = remainingSessions.sort(compareSessionsByAnchorTime)[0]; const meta = await getMeta(); await setMeta({ -- 2.54.0 From 4f54da64d0a6f443b5d927527b785b372c4ef754 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 19 May 2026 17:54:09 +0800 Subject: [PATCH 139/281] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E7=BC=96=E8=BE=91=E5=92=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E9=80=BB=E8=BE=91=EF=BC=9B=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=BC=9A=E8=AF=9D=E6=97=B6=E9=97=B4=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentHeader.test.tsx | 2 +- src/components/chat/AgentHeader.tsx | 63 +++--- src/components/chat/AgentHistoryPanel.tsx | 183 ++++++++---------- src/components/chat/chatStorage.test.ts | 142 ++++++++++++++ src/components/chat/chatStorage.ts | 17 +- .../chat/hooks/useAgentChatSession.ts | 58 +++++- 6 files changed, 329 insertions(+), 136 deletions(-) create mode 100644 src/components/chat/chatStorage.test.ts diff --git a/src/components/chat/AgentHeader.test.tsx b/src/components/chat/AgentHeader.test.tsx index 53929c6..a3d0dd1 100644 --- a/src/components/chat/AgentHeader.test.tsx +++ b/src/components/chat/AgentHeader.test.tsx @@ -33,7 +33,7 @@ describe("AgentHeader", () => { fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), { target: { value: "更新后的标题" }, }); - fireEvent.click(screen.getByLabelText("确认修改对话标题")); + fireEvent.click(screen.getByLabelText("确认")); expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题"); }); diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index 2573158..3682457 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -129,10 +129,9 @@ export const AgentHeader = ({ /> </Box> </motion.div> - <Box sx={{ minWidth: 0, minHeight: 52, display: "flex", flexDirection: "column", justifyContent: "center" }}> + <Box sx={{ minWidth: 0 }}> {isEditingTitle ? ( - <Box> - <Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 }, transform: "translateY(2px)" }}> + <Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 } }}> <TextField value={draftTitle} onChange={(event) => setDraftTitle(event.target.value)} @@ -152,27 +151,35 @@ export const AgentHeader = ({ flex: 1, minWidth: 0, "& .MuiOutlinedInput-root": { - height: 34, - bgcolor: alpha("#fff", 0.7), + padding: "6px 8px", + bgcolor: "transparent", borderRadius: 1.5, transition: "all 0.2s ease-in-out", + "&.Mui-focused": { + bgcolor: alpha("#fff", 0.6), + boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`, + }, "& fieldset": { - borderColor: alpha("#000", 0.08), + borderColor: "transparent", }, "&:hover fieldset": { - borderColor: alpha(theme.palette.primary.main, 0.4), + borderColor: alpha(theme.palette.primary.main, 0.2), }, "&.Mui-focused fieldset": { - borderColor: theme.palette.primary.main, - borderWidth: "1.5px", - boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`, + borderColor: alpha(theme.palette.primary.main, 0.5), + borderWidth: "1px", }, }, "& .MuiInputBase-input": { - padding: "4px 12px", - fontSize: "1.05rem", - fontWeight: 700, - color: theme.palette.text.primary, + padding: 0, + height: "auto", + fontSize: "1.25rem", + fontWeight: 800, + letterSpacing: -0.3, + lineHeight: "1.2", + background: `linear-gradient(90deg, #01579b, #00838f)`, + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", } }} /> @@ -206,22 +213,22 @@ export const AgentHeader = ({ <CloseRounded sx={{ fontSize: 18 }} /> </IconButton> </Stack> - </Box> ) : ( - <> - <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}> - <Typography + <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}> + <Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, #01579b, #00838f)`, - backgroundClip: "text", - color: "transparent", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", letterSpacing: -0.3, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: { xs: "calc(100vw - 256px)", sm: 284 }, + px: "8px", + lineHeight: 1.2, }} > {displayTitle} @@ -235,8 +242,8 @@ export const AgentHeader = ({ onClick={handleStartEditing} disabled={isHydrating || isStreaming} sx={{ - width: 24, - height: 24, + width: 30, + height: 30, color: "text.secondary", bgcolor: alpha("#fff", 0.45), "&:hover": { @@ -245,20 +252,12 @@ export const AgentHeader = ({ }, }} > - <EditRounded sx={{ fontSize: 16 }} /> + <EditRounded sx={{ fontSize: 18 }} /> </IconButton> </span> </Tooltip> ) : null} - </Stack> - <Typography variant="caption" color="text.secondary" fontWeight={500}> - {isStreaming - ? "正在思考分析任务..." - : displayTitle === "TJWater Agent" - ? "基于大模型的水力分析引擎" - : "当前会话标题"} - </Typography> - </> + </Stack> )} </Box> </Stack> diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index 496b816..79230a2 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -85,7 +85,6 @@ export const AgentHistoryPanel = ({ const [keyword, setKeyword] = React.useState(""); const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null); const [draftTitle, setDraftTitle] = React.useState(""); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null); const filteredSessions = React.useMemo(() => { @@ -300,7 +299,7 @@ export const AgentHistoryPanel = ({ }, }} > - <Stack direction="row" spacing={1} alignItems="flex-start"> + <Stack direction="row" spacing={1} alignItems="center"> <Box sx={{ flex: 1, minWidth: 0 }}> {editingSessionId === session.id ? ( <Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}> @@ -386,6 +385,38 @@ export const AgentHistoryPanel = ({ <CloseRounded sx={{ fontSize: 16 }} /> </IconButton> </Stack> + ) : pendingDeleteSessionId === session.id ? ( + <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}> + <Box + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 20, + height: 20, + borderRadius: "50%", + bgcolor: alpha("#ef5350", 0.15), + color: "#ef5350", + flexShrink: 0 + }} + > + <WarningRounded sx={{ fontSize: 13 }} /> + </Box> + <Typography + variant="body2" + fontWeight={800} + color="error.main" + sx={{ + flex: 1, + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + 确认删除此会话? + </Typography> + </Stack> ) : ( <Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}> <Typography @@ -409,8 +440,9 @@ export const AgentHistoryPanel = ({ )} </Box> - <Stack direction="row" spacing={0.25} sx={{ display: editingSessionId === session.id ? 'none' : 'flex' }}> - <Tooltip title="修改会话标题"> + {!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && ( + <Stack direction="row" spacing={0.25}> + <Tooltip title="修改会话标题"> <span> <IconButton size="small" @@ -421,8 +453,8 @@ export const AgentHistoryPanel = ({ }} disabled={isHydrating || editingSessionId === session.id} sx={{ - width: 24, - height: 24, + width: 28, + height: 28, color: "text.secondary", "&:hover": { color: "primary.main", @@ -442,11 +474,11 @@ export const AgentHistoryPanel = ({ onClick={(event) => { event.stopPropagation(); setPendingDeleteSessionId(session.id); - setIsDeleteDialogOpen(true); }} + disabled={isHydrating} sx={{ - width: 24, - height: 24, + width: 28, + height: 28, color: "text.secondary", "&:hover": { color: "error.main", @@ -459,6 +491,48 @@ export const AgentHistoryPanel = ({ </span> </Tooltip> </Stack> + )} + + {pendingDeleteSessionId === session.id && ( + <Stack direction="row" spacing={0.5} alignItems="center"> + <IconButton + size="small" + aria-label="确认删除" + onClick={(event) => { + event.stopPropagation(); + onDeleteSession(session.id); + setPendingDeleteSessionId(null); + }} + sx={{ + width: 28, + height: 28, + color: "error.main", + bgcolor: alpha("#ef5350", 0.1), + "&:hover": { bgcolor: alpha("#ef5350", 0.2) }, + }} + > + <CheckRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton + size="small" + aria-label="取消删除" + onClick={(event) => { + event.stopPropagation(); + setPendingDeleteSessionId(null); + }} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.05), + "&:hover": { bgcolor: alpha("#000", 0.1) }, + }} + > + <CloseRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Stack> + )} + </Stack> </Paper> ); @@ -470,97 +544,6 @@ export const AgentHistoryPanel = ({ )} </Box> </Paper> - - <Dialog - open={isDeleteDialogOpen} - onClose={() => setIsDeleteDialogOpen(false)} - sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }} - TransitionProps={{ - onExited: () => setPendingDeleteSessionId(null) - }} - PaperProps={{ - sx: { - borderRadius: 4, - bgcolor: alpha("#fff", 0.85), - backdropFilter: "blur(24px)", - boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`, - border: `1px solid ${alpha("#fff", 0.6)}`, - minWidth: 320, - }, - }} - > - <DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, pb: 1, pt: 3, px: 3 }}> - <Box - sx={{ - display: "flex", - alignItems: "center", - justifyContent: "center", - width: 40, - height: 40, - borderRadius: "50%", - bgcolor: alpha("#ef5350", 0.12), - color: "#ef5350", - }} - > - <WarningRounded sx={{ fontSize: 22 }} /> - </Box> - <Typography component="span" variant="h6" fontWeight={800} color="text.primary"> - 删除确认 - </Typography> - </DialogTitle> - <DialogContent sx={{ px: 3, pb: 2 }}> - <DialogContentText color="text.secondary" sx={{ fontSize: "0.95rem" }}> - 确定要删除 - {pendingDeleteSession ? ( - <Typography component="span" fontWeight={700} color="text.primary"> - “{pendingDeleteSession.title}” - </Typography> - ) : ( - "该会话" - )} - 吗? - <br /> - 此操作不可撤销,删除后聊天记录将永久丢失。 - </DialogContentText> - </DialogContent> - <DialogActions sx={{ px: 3, pb: 3, pt: 1 }}> - <Button - onClick={() => setIsDeleteDialogOpen(false)} - sx={{ - color: "text.secondary", - fontWeight: 600, - borderRadius: 2.5, - px: 2.5, - "&:hover": { bgcolor: alpha("#000", 0.04) }, - }} - > - 取消 - </Button> - <Button - variant="contained" - onClick={() => { - if (pendingDeleteSessionId) { - onDeleteSession(pendingDeleteSessionId); - } - setIsDeleteDialogOpen(false); - }} - sx={{ - bgcolor: "#ef5350", - color: "#fff", - fontWeight: 700, - borderRadius: 2.5, - px: 3, - boxShadow: `0 4px 12px ${alpha("#ef5350", 0.3)}`, - "&:hover": { - bgcolor: "#e53935", - boxShadow: `0 6px 16px ${alpha("#ef5350", 0.4)}`, - }, - }} - > - 确认删除 - </Button> - </DialogActions> - </Dialog> </> ); }; diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts new file mode 100644 index 0000000..18f7119 --- /dev/null +++ b/src/components/chat/chatStorage.test.ts @@ -0,0 +1,142 @@ +import type { ChatSessionRecord } from "./GlobalChatbox.types"; +import { + createEmptyChatSession, + loadChatSessionById, + saveActiveChatState, + updateChatSessionTitle, +} from "./chatStorage"; + +type StoreName = "sessions" | "meta"; + +const stores: Record<StoreName, Map<string, any>> = { + sessions: new Map(), + meta: new Map(), +}; + +const mockDb = { + get: jest.fn(async (storeName: StoreName, key: string) => stores[storeName].get(key)), + getAll: jest.fn(async (storeName: StoreName) => Array.from(stores[storeName].values())), + put: jest.fn(async (storeName: StoreName, value: { id?: string; key?: string }) => { + const key = storeName === "sessions" ? value.id : value.key; + if (!key) { + throw new Error(`Missing key for store ${storeName}`); + } + stores[storeName].set(key, value); + return key; + }), + delete: jest.fn(async (storeName: StoreName, key: string) => { + stores[storeName].delete(key); + }), +}; + +jest.mock("idb", () => ({ + openDB: jest.fn(async () => mockDb), +})); + +describe("chatStorage timestamp semantics", () => { + let now = new Date("2026-05-19T09:00:00+08:00").getTime(); + let dateNowSpy: jest.SpyInstance<number, []>; + + beforeEach(() => { + stores.sessions.clear(); + stores.meta.clear(); + mockDb.get.mockClear(); + mockDb.getAll.mockClear(); + mockDb.put.mockClear(); + mockDb.delete.mockClear(); + window.localStorage.clear(); + now = new Date("2026-05-19T09:00:00+08:00").getTime(); + dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + it("keeps anchor and content timestamps when reopening an old session", async () => { + const record: ChatSessionRecord = { + id: "old-session", + title: "很久之前的会话", + isTitleManuallyEdited: false, + createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(), + updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(), + sessionId: "remote-1", + messages: [ + { + id: "message-1", + role: "user", + content: "老问题", + branchRootId: "message-1", + }, + ], + branchGroups: [], + }; + stores.sessions.set(record.id, record); + + const loadedState = await loadChatSessionById(record.id); + now = new Date("2026-05-19T09:30:00+08:00").getTime(); + await saveActiveChatState(loadedState); + + expect(stores.sessions.get(record.id)).toMatchObject({ + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + }); + + it("does not change timestamps when renaming a session", async () => { + const record: ChatSessionRecord = { + id: "rename-session", + title: "旧标题", + isTitleManuallyEdited: false, + createdAt: new Date("2026-04-10T08:00:00+08:00").getTime(), + updatedAt: new Date("2026-04-10T08:05:00+08:00").getTime(), + sessionId: "remote-2", + messages: [ + { + id: "message-2", + role: "user", + content: "保留时间", + branchRootId: "message-2", + }, + ], + branchGroups: [], + }; + stores.sessions.set(record.id, record); + + now = new Date("2026-05-19T11:00:00+08:00").getTime(); + await updateChatSessionTitle(record.id, "新标题", { + isTitleManuallyEdited: true, + }); + + expect(stores.sessions.get(record.id)).toMatchObject({ + title: "新标题", + isTitleManuallyEdited: true, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + }); + + it("anchors createdAt to the first real message time for a new empty session", async () => { + const emptyState = await createEmptyChatSession(); + const storageSessionId = emptyState.storageSessionId; + + now = new Date("2026-05-19T09:05:00+08:00").getTime(); + await saveActiveChatState({ + ...emptyState, + messages: [ + { + id: "message-3", + role: "user", + content: "第一条消息", + branchRootId: "message-3", + }, + ], + sessionId: "remote-3", + }); + + expect(stores.sessions.get(storageSessionId!)).toMatchObject({ + createdAt: now, + updatedAt: now, + }); + }); +}); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 51d8a34..280b996 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -51,6 +51,17 @@ const sanitizeMessages = (messages: Message[] | undefined) => const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; +const serializeConversationState = (state: { + messages: Message[]; + branchGroups: BranchGroup[]; + sessionId?: string; +}) => + JSON.stringify({ + messages: sanitizeMessages(state.messages), + branchGroups: sanitizeBranchGroups(state.branchGroups), + sessionId: state.sessionId ?? null, + }); + const hasChatContent = (state: { messages: Message[]; branchGroups: BranchGroup[]; @@ -260,6 +271,9 @@ export const saveActiveChatState = async ( const storageSessionId = state.storageSessionId ?? createId(); const preferredTitle = state.title?.trim(); const finalTitle = preferredTitle || existingSession?.title || "新对话"; + const hasContentChanged = + !existingSession || + serializeConversationState(existingSession) !== serializeConversationState(state); const shouldAnchorCreatedAtToFirstMessage = Boolean(existingSession) && !hasChatContent(existingSession) && hasContent; const nextRecord: ChatSessionRecord = { @@ -269,7 +283,7 @@ export const saveActiveChatState = async ( createdAt: shouldAnchorCreatedAtToFirstMessage ? now : existingSession?.createdAt ?? now, - updatedAt: now, + updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now, sessionId: state.sessionId, messages: sanitizeMessages(state.messages), branchGroups: sanitizeBranchGroups(state.branchGroups), @@ -315,7 +329,6 @@ export const updateChatSessionTitle = async ( title: normalizedTitle, isTitleManuallyEdited: options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false, - updatedAt: Date.now(), }); }; diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index b517898..2ac31db 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -48,6 +48,16 @@ type PromptRunOptions = { assistantMessage?: Message; }; +const createPersistedStateKey = (state: LoadedChatState) => + JSON.stringify({ + storageSessionId: state.storageSessionId ?? null, + title: state.title ?? null, + isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, + sessionId: state.sessionId ?? null, + messages: state.messages, + branchGroups: state.branchGroups, + }); + const upsertProgress = ( progress: ChatProgress[] | undefined, event: StreamEvent & { type: "progress" }, @@ -158,6 +168,16 @@ export const useAgentChatSession = ({ const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef<Promise<void> | null>(null); const titleUpdateNonceRef = useRef(0); + const lastPersistedStateKeyRef = useRef( + createPersistedStateKey({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }), + ); useEffect(() => { sessionIdRef.current = sessionId; @@ -180,6 +200,7 @@ export const useAgentChatSession = ({ storageSessionIdRef.current = loadedState.storageSessionId; sessionIdRef.current = loadedState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState); hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; @@ -219,11 +240,19 @@ export const useAgentChatSession = ({ sessionId, branchGroups, }; + const currentStateKey = createPersistedStateKey(state); + if (currentStateKey === lastPersistedStateKeyRef.current) { + return; + } void saveActiveChatState(state) .then((storageSessionId) => { if (hydrationNonceRef.current !== currentHydrationNonce) return; storageSessionIdRef.current = storageSessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + ...state, + storageSessionId, + }); return listChatSessions(); }) .then((sessions) => { @@ -503,6 +532,14 @@ export const useAgentChatSession = ({ setSessionId(undefined); sessionIdRef.current = undefined; storageSessionIdRef.current = undefined; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }); titleUpdateNonceRef.current += 1; setIsStreaming(false); }, []); @@ -521,6 +558,7 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = newState.storageSessionId; sessionIdRef.current = newState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(newState); setMessages(newState.messages); setSessionTitle(newState.title); setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false); @@ -547,6 +585,7 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); @@ -581,6 +620,14 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = undefined; sessionIdRef.current = undefined; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }); setBranchTransition(null); setMessages([]); setSessionTitle(undefined); @@ -599,6 +646,7 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; + lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); @@ -637,12 +685,20 @@ export const useAgentChatSession = ({ if (storageSessionIdRef.current === targetStorageSessionId) { setSessionTitle(normalizedTitle); setIsSessionTitleManuallyEdited(true); + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: targetStorageSessionId, + title: normalizedTitle, + isTitleManuallyEdited: true, + messages, + sessionId: sessionIdRef.current, + branchGroups, + }); } } catch (error) { console.error("[GlobalChatbox] Failed to rename chat session:", error); } }, - [isHydrating], + [branchGroups, isHydrating, messages], ); const regenerate = useCallback(async () => { -- 2.54.0 From 91a57123a4008c8731481b17cc8149be2c1d8636 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 19 May 2026 17:55:05 +0800 Subject: [PATCH 140/281] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=EF=BC=8C=E6=8F=90=E5=8D=87=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/chatStorage.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 280b996..46fc1d6 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -84,7 +84,9 @@ const compareSessionsByAnchorTime = ( return right.id.localeCompare(left.id); }; -const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => { +const toLoadedChatState = ( + session: ChatSessionRecord | undefined, +): LoadedChatState => { if (!session) return emptyLoadedChatState(); return { storageSessionId: session.id, @@ -107,7 +109,9 @@ const getDb = () => openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(SESSION_STORE)) { - const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: "id" }); + const sessionStore = db.createObjectStore(SESSION_STORE, { + keyPath: "id", + }); sessionStore.createIndex("by-updatedAt", "updatedAt"); } @@ -273,13 +277,17 @@ export const saveActiveChatState = async ( const finalTitle = preferredTitle || existingSession?.title || "新对话"; const hasContentChanged = !existingSession || - serializeConversationState(existingSession) !== serializeConversationState(state); + (existingSession && serializeConversationState(existingSession)) !== + serializeConversationState(state); const shouldAnchorCreatedAtToFirstMessage = - Boolean(existingSession) && !hasChatContent(existingSession) && hasContent; + existingSession && !hasChatContent(existingSession) && hasContent; const nextRecord: ChatSessionRecord = { id: storageSessionId, title: finalTitle, - isTitleManuallyEdited: state.isTitleManuallyEdited ?? existingSession?.isTitleManuallyEdited ?? false, + isTitleManuallyEdited: + state.isTitleManuallyEdited ?? + existingSession?.isTitleManuallyEdited ?? + false, createdAt: shouldAnchorCreatedAtToFirstMessage ? now : existingSession?.createdAt ?? now, @@ -360,7 +368,9 @@ export const createEmptyChatSession = async (): Promise<LoadedChatState> => { return toLoadedChatState(session); }; -export const loadChatSessionById = async (sessionId: string): Promise<LoadedChatState> => { +export const loadChatSessionById = async ( + sessionId: string, +): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); await migrateLegacyLocalStorage(); @@ -380,14 +390,18 @@ export const loadChatSessionById = async (sessionId: string): Promise<LoadedChat return toLoadedChatState(session); }; -export const deleteChatSession = async (sessionId: string): Promise<string | undefined> => { +export const deleteChatSession = async ( + sessionId: string, +): Promise<string | undefined> => { if (typeof window === "undefined") return undefined; const db = await getDb(); await db.delete(SESSION_STORE, sessionId); const remainingSessions = await db.getAll(SESSION_STORE); - const nextActiveSession = remainingSessions.sort(compareSessionsByAnchorTime)[0]; + const nextActiveSession = remainingSessions.sort( + compareSessionsByAnchorTime, + )[0]; const meta = await getMeta(); await setMeta({ -- 2.54.0 From adf8ea5ca88043ad3f65387b872b28f62e6a5e3a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 19 May 2026 18:06:39 +0800 Subject: [PATCH 141/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index a02769e..eb6ccf2 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -4,6 +4,7 @@ export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; export const PRESET_PROMPTS = [ "分析当前管网中的水力瓶颈管道,并给出改造建议。", + "供水服务分区分析。", "帮我分析当前管网压力异常点,并按风险等级排序。", "帮我生成一份今日运行简报,包含问题、原因和建议。", "查询关键 SCADA 点位最近 24 小时的异常波动。", -- 2.54.0 From 98635e5247a245d408d42fd06f935452a85eea8b Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 20 May 2026 11:43:58 +0800 Subject: [PATCH 142/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=86=E5=AE=BD=E5=BA=A6=E9=99=90=E5=88=B6=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=20header=20=E6=8C=89=E9=92=AE=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentHeader.tsx | 11 +++++------ src/components/chat/GlobalChatbox.tsx | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index 3682457..d6454ff 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -87,8 +87,8 @@ export const AgentHeader = ({ boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`, }} > - <Stack direction="row" alignItems="center" spacing={2}> - <motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex" }}> + <Stack direction="row" alignItems="center" spacing={2} sx={{ minWidth: 0, flex: 1, mr: 2 }}> + <motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex", flexShrink: 0 }}> <Box sx={{ position: "relative" }}> <Avatar sx={{ @@ -129,9 +129,9 @@ export const AgentHeader = ({ /> </Box> </motion.div> - <Box sx={{ minWidth: 0 }}> + <Box sx={{ minWidth: 0, flex: 1 }}> {isEditingTitle ? ( - <Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: { xs: "calc(100vw - 256px)", sm: 280 } }}> + <Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: "100%" }}> <TextField value={draftTitle} onChange={(event) => setDraftTitle(event.target.value)} @@ -226,7 +226,6 @@ export const AgentHeader = ({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", - maxWidth: { xs: "calc(100vw - 256px)", sm: 284 }, px: "8px", lineHeight: 1.2, }} @@ -262,7 +261,7 @@ export const AgentHeader = ({ </Box> </Stack> - <Stack direction="row" spacing={1.25} alignItems="center"> + <Stack direction="row" spacing={1.25} alignItems="center" sx={{ flexShrink: 0 }}> <Tooltip title="新建对话"> <motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}> <IconButton diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 8f4db76..010405a 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -159,7 +159,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleMouseMove = (event: MouseEvent) => { if (!isResizing) return; const newWidth = window.innerWidth - event.clientX; - if (newWidth > 360 && newWidth < 1240) { + if (newWidth > 360 && newWidth < 800) { setWidth(newWidth); } }; -- 2.54.0 From 424555aae2455cb04fe9d533127eeaf0f325ad48 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 20 May 2026 15:33:43 +0800 Subject: [PATCH 143/281] =?UTF-8?q?=E6=97=A0=E5=AF=B9=E8=AF=9D=E7=9A=84?= =?UTF-8?q?=E6=96=B0=E5=AF=B9=E8=AF=9D=E4=B8=8D=E8=BF=9B=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/hooks/useAgentChatSession.test.tsx | 101 ++++++++++++++++++ .../chat/hooks/useAgentChatSession.ts | 31 +++--- 2 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 src/components/chat/hooks/useAgentChatSession.test.tsx diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx new file mode 100644 index 0000000..14f391f --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { useAgentChatSession } from "./useAgentChatSession"; + +jest.mock("@/lib/chatStream", () => ({ + abortAgentChat: jest.fn(async () => undefined), + forkAgentChat: jest.fn(async () => "forked-session"), + streamAgentChat: jest.fn(async () => undefined), +})); + +const loadActiveChatState = jest.fn(); +const listChatSessions = jest.fn(); + +jest.mock("../chatStorage", () => ({ + deleteChatSession: jest.fn(async () => undefined), + listChatSessions: (...args: unknown[]) => listChatSessions(...args), + loadActiveChatState: (...args: unknown[]) => loadActiveChatState(...args), + loadChatSessionById: jest.fn(async () => ({ + storageSessionId: "session-loaded", + title: "已存在会话", + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + })), + saveActiveChatState: jest.fn(async (state) => state.storageSessionId), + updateChatSessionTitle: jest.fn(async () => undefined), +})); + +describe("useAgentChatSession", () => { + beforeEach(() => { + loadActiveChatState.mockReset(); + listChatSessions.mockReset(); + + loadActiveChatState.mockResolvedValue({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }); + }); + + it("does not add a new empty session to history until there is actual chat content", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.createSession(); + }); + + await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); + expect(result.current.chatSessions).toEqual([]); + expect(result.current.activeStorageSessionId).toBeUndefined(); + expect(result.current.messages).toEqual([]); + expect(result.current.isStreaming).toBe(false); + expect(listChatSessions).toHaveBeenCalledTimes(1); + }); + + it("keeps existing history entries when creating a blank new session", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-1", + title: "已有会话", + createdAt: 1, + updatedAt: 1, + }, + ]); + + const { result } = renderHook(() => + useAgentChatSession({ + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.createSession(); + }); + + expect(result.current.chatSessions).toEqual([ + { + id: "session-1", + title: "已有会话", + createdAt: 1, + updatedAt: 1, + }, + ]); + }); +}); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 2ac31db..e716cb1 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -9,17 +9,14 @@ import type { BranchGroup, BranchTransition, ChatProgress, - ChatSessionSummary, LoadedChatState, Message, } from "../GlobalChatbox.types"; import { cloneBranchGroups, cloneMessages, - createId, } from "../GlobalChatbox.utils"; import { - createEmptyChatSession, deleteChatSession, listChatSessions, loadActiveChatState, @@ -550,21 +547,23 @@ export const useAgentChatSession = ({ const controller = abortRef.current; controller?.abort(); setBranchTransition(null); - - const newState = await createEmptyChatSession(); - const sessions = await listChatSessions(); - hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - storageSessionIdRef.current = newState.storageSessionId; - sessionIdRef.current = newState.sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey(newState); - setMessages(newState.messages); - setSessionTitle(newState.title); - setIsSessionTitleManuallyEdited(newState.isTitleManuallyEdited ?? false); - setSessionId(newState.sessionId); - setBranchGroups(newState.branchGroups); - setChatSessions(sessions); + storageSessionIdRef.current = undefined; + sessionIdRef.current = undefined; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: undefined, + title: "新对话", + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }); + setMessages([]); + setSessionTitle("新对话"); + setIsSessionTitleManuallyEdited(false); + setSessionId(undefined); + setBranchGroups([]); setIsStreaming(false); }, [isHydrating, isStreaming]); -- 2.54.0 From 477350a2a1118a83ac55c8214874de398e1bfddc Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 20 May 2026 15:43:35 +0800 Subject: [PATCH 144/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/hooks/useAgentChatSession.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index e716cb1..19dc5f5 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -15,6 +15,7 @@ import type { import { cloneBranchGroups, cloneMessages, + createId, } from "../GlobalChatbox.utils"; import { deleteChatSession, -- 2.54.0 From e4d45300b161c1551238ff99a999b2861f23112c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 20 May 2026 16:14:57 +0800 Subject: [PATCH 145/281] Fix missing chat session summary import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/chat/hooks/useAgentChatSession.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 19dc5f5..883c349 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -9,6 +9,7 @@ import type { BranchGroup, BranchTransition, ChatProgress, + ChatSessionSummary, LoadedChatState, Message, } from "../GlobalChatbox.types"; -- 2.54.0 From 4bf99e8069bf46dd00870089fe54718cc240c6ee Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 21 May 2026 17:33:48 +0800 Subject: [PATCH 146/281] Refine chat session storage and title handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 7 - package.json | 1 - src/components/chat/AgentHeader.test.tsx | 40 -- src/components/chat/AgentHeader.tsx | 2 +- src/components/chat/chatStorage.test.ts | 177 +++--- src/components/chat/chatStorage.ts | 508 ++++++++---------- .../chat/hooks/useAgentChatSession.test.tsx | 48 +- 7 files changed, 330 insertions(+), 453 deletions(-) delete mode 100644 src/components/chat/AgentHeader.test.tsx diff --git a/package-lock.json b/package-lock.json index f7b32e5..ae9fa19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "framer-motion": "^12.38.0", - "idb": "^8.0.3", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", @@ -15844,12 +15843,6 @@ "node": ">=0.10.0" } }, - "node_modules/idb": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", - "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", - "license": "ISC" - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index da5bcab..8460d87 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "framer-motion": "^12.38.0", - "idb": "^8.0.3", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", diff --git a/src/components/chat/AgentHeader.test.tsx b/src/components/chat/AgentHeader.test.tsx deleted file mode 100644 index a3d0dd1..0000000 --- a/src/components/chat/AgentHeader.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { ThemeProvider, createTheme } from "@mui/material/styles"; - -import { AgentHeader } from "./AgentHeader"; - -jest.mock("next/image", () => ({ - __esModule: true, - default: (props: React.ComponentProps<"img">) => <img {...props} alt={props.alt ?? ""} />, -})); - -const renderWithTheme = (ui: React.ReactElement) => - render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>); - -describe("AgentHeader", () => { - it("submits a renamed active session title", () => { - const onRenameSessionTitle = jest.fn(); - - renderWithTheme( - <AgentHeader - sessionTitle="原始标题" - canRenameSessionTitle - isStreaming={false} - isHistoryOpen={false} - onHistoryToggle={jest.fn()} - onRenameSessionTitle={onRenameSessionTitle} - onNewConversation={jest.fn()} - onClose={jest.fn()} - />, - ); - - fireEvent.click(screen.getByRole("button", { name: "修改对话标题" })); - fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), { - target: { value: "更新后的标题" }, - }); - fireEvent.click(screen.getByLabelText("确认")); - - expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题"); - }); -}); diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index d6454ff..ee19419 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -44,7 +44,7 @@ export const AgentHeader = ({ onClose, }: AgentHeaderProps) => { const theme = useTheme(); - const displayTitle = sessionTitle?.trim() || "TJWater Agent"; + const displayTitle = sessionTitle?.trim() || "新对话"; const [isEditingTitle, setIsEditingTitle] = React.useState(false); const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index 18f7119..e602d3b 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -1,142 +1,99 @@ -import type { ChatSessionRecord } from "./GlobalChatbox.types"; import { createEmptyChatSession, - loadChatSessionById, + loadActiveChatState, saveActiveChatState, - updateChatSessionTitle, } from "./chatStorage"; -type StoreName = "sessions" | "meta"; +const apiFetch = jest.fn(); -const stores: Record<StoreName, Map<string, any>> = { - sessions: new Map(), - meta: new Map(), -}; - -const mockDb = { - get: jest.fn(async (storeName: StoreName, key: string) => stores[storeName].get(key)), - getAll: jest.fn(async (storeName: StoreName) => Array.from(stores[storeName].values())), - put: jest.fn(async (storeName: StoreName, value: { id?: string; key?: string }) => { - const key = storeName === "sessions" ? value.id : value.key; - if (!key) { - throw new Error(`Missing key for store ${storeName}`); - } - stores[storeName].set(key, value); - return key; - }), - delete: jest.fn(async (storeName: StoreName, key: string) => { - stores[storeName].delete(key); - }), -}; - -jest.mock("idb", () => ({ - openDB: jest.fn(async () => mockDb), +jest.mock("@/lib/apiFetch", () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), })); -describe("chatStorage timestamp semantics", () => { - let now = new Date("2026-05-19T09:00:00+08:00").getTime(); - let dateNowSpy: jest.SpyInstance<number, []>; - +describe("chatStorage backend-only persistence", () => { beforeEach(() => { - stores.sessions.clear(); - stores.meta.clear(); - mockDb.get.mockClear(); - mockDb.getAll.mockClear(); - mockDb.put.mockClear(); - mockDb.delete.mockClear(); window.localStorage.clear(); - now = new Date("2026-05-19T09:00:00+08:00").getTime(); - dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now); + apiFetch.mockReset(); }); - afterEach(() => { - dateNowSpy.mockRestore(); - }); + it("loads the active remote session when localStorage has an active id", async () => { + window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1"); - it("keeps anchor and content timestamps when reopening an old session", async () => { - const record: ChatSessionRecord = { - id: "old-session", - title: "很久之前的会话", - isTitleManuallyEdited: false, - createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(), - updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(), - sessionId: "remote-1", - messages: [ - { - id: "message-1", - role: "user", - content: "老问题", - branchRootId: "message-1", - }, - ], - branchGroups: [], - }; - stores.sessions.set(record.id, record); - - const loadedState = await loadChatSessionById(record.id); - now = new Date("2026-05-19T09:30:00+08:00").getTime(); - await saveActiveChatState(loadedState); - - expect(stores.sessions.get(record.id)).toMatchObject({ - createdAt: record.createdAt, - updatedAt: record.updatedAt, + apiFetch.mockImplementation(async (url: string) => { + if (url.endsWith("/api/v1/agent/chat/session/chat-active-1")) { + return { + ok: true, + json: async () => ({ + id: "chat-active-1", + title: "已存在会话", + is_title_manually_edited: false, + session_id: "chat-active-1", + messages: [], + branch_groups: [], + }), + } as Response; + } + throw new Error(`Unexpected request ${url}`); }); + + const loaded = await loadActiveChatState(); + + expect(loaded.storageSessionId).toBe("chat-active-1"); + expect(loaded.title).toBe("已存在会话"); }); - it("does not change timestamps when renaming a session", async () => { - const record: ChatSessionRecord = { - id: "rename-session", - title: "旧标题", + it("creates a backend conversation when saving the first non-empty state", async () => { + apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith("/api/v1/agent/chat/session")) { + expect(init?.method).toBe("POST"); + return { + ok: true, + json: async () => ({ session_id: "chat-new-1" }), + } as Response; + } + + if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) { + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + title: "新对话", + is_title_manually_edited: false, + }); + return { + ok: true, + json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }), + } as Response; + } + + throw new Error(`Unexpected request ${url}`); + }); + + const savedSessionId = await saveActiveChatState({ + storageSessionId: undefined, + title: "新对话", isTitleManuallyEdited: false, - createdAt: new Date("2026-04-10T08:00:00+08:00").getTime(), - updatedAt: new Date("2026-04-10T08:05:00+08:00").getTime(), - sessionId: "remote-2", messages: [ { id: "message-2", role: "user", - content: "保留时间", + content: "第一条消息", branchRootId: "message-2", }, ], + sessionId: undefined, branchGroups: [], - }; - stores.sessions.set(record.id, record); - - now = new Date("2026-05-19T11:00:00+08:00").getTime(); - await updateChatSessionTitle(record.id, "新标题", { - isTitleManuallyEdited: true, }); - expect(stores.sessions.get(record.id)).toMatchObject({ - title: "新标题", - isTitleManuallyEdited: true, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }); + expect(savedSessionId).toBe("chat-new-1"); + expect(window.localStorage.getItem("tjwater_agent_active_session_id_v2")).toBe( + "chat-new-1", + ); }); - it("anchors createdAt to the first real message time for a new empty session", async () => { - const emptyState = await createEmptyChatSession(); - const storageSessionId = emptyState.storageSessionId; + it("does not persist a blank new session before there is chat content", async () => { + const session = await createEmptyChatSession(); - now = new Date("2026-05-19T09:05:00+08:00").getTime(); - await saveActiveChatState({ - ...emptyState, - messages: [ - { - id: "message-3", - role: "user", - content: "第一条消息", - branchRootId: "message-3", - }, - ], - sessionId: "remote-3", - }); - - expect(stores.sessions.get(storageSessionId!)).toMatchObject({ - createdAt: now, - updatedAt: now, - }); + expect(session.storageSessionId).toBeUndefined(); + expect(session.title).toBe("新对话"); + expect(apiFetch).not.toHaveBeenCalled(); }); }); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 46fc1d6..dd2a974 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -1,39 +1,21 @@ -import { openDB, type DBSchema } from "idb"; +import { apiFetch } from "@/lib/apiFetch"; +import { config } from "@config/config"; import type { BranchGroup, - ChatSessionRecord, ChatSessionSummary, - ChatStorageMeta, - LegacyPersistedChatState, LoadedChatState, Message, } from "./GlobalChatbox.types"; -import { - cloneBranchGroups, - cloneMessages, - createId, -} from "./GlobalChatbox.utils"; +import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; -const CHAT_DB_NAME = "tjwater-agent-chat"; -const CHAT_DB_VERSION = 1; -const SESSION_STORE = "sessions"; -const META_STORE = "meta"; -const META_KEY = "chat-meta" as const; -const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1"; +const ACTIVE_SESSION_STORAGE_KEY = "tjwater_agent_active_session_id_v2"; -type ChatDB = DBSchema & { - sessions: { - key: string; - value: ChatSessionRecord; - indexes: { - "by-updatedAt": number; - }; - }; - meta: { - key: string; - value: ChatStorageMeta; - }; +type RemoteSessionPayload = { + id?: string; + title?: string; + created_at?: string | number; + updated_at?: string | number; }; const emptyLoadedChatState = (): LoadedChatState => ({ @@ -51,17 +33,6 @@ const sanitizeMessages = (messages: Message[] | undefined) => const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; -const serializeConversationState = (state: { - messages: Message[]; - branchGroups: BranchGroup[]; - sessionId?: string; -}) => - JSON.stringify({ - messages: sanitizeMessages(state.messages), - branchGroups: sanitizeBranchGroups(state.branchGroups), - sessionId: state.sessionId ?? null, - }); - const hasChatContent = (state: { messages: Message[]; branchGroups: BranchGroup[]; @@ -72,8 +43,8 @@ const hasChatContent = (state: { Boolean(state.sessionId); const compareSessionsByAnchorTime = ( - left: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">, - right: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">, + left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">, + right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">, ) => { const createdAtDiff = right.createdAt - left.createdAt; if (createdAtDiff !== 0) return createdAtDiff; @@ -84,167 +55,203 @@ const compareSessionsByAnchorTime = ( return right.id.localeCompare(left.id); }; -const toLoadedChatState = ( - session: ChatSessionRecord | undefined, -): LoadedChatState => { - if (!session) return emptyLoadedChatState(); - return { - storageSessionId: session.id, - title: session.title, - isTitleManuallyEdited: session.isTitleManuallyEdited ?? false, - messages: sanitizeMessages(session.messages), - sessionId: session.sessionId, - branchGroups: sanitizeBranchGroups(session.branchGroups), - }; +const toMillis = (value: string | number | undefined) => + typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now(); + +const normalizeTitle = (value?: string) => value?.trim() || "新对话"; + +const getStoredActiveSessionId = () => { + if (typeof window === "undefined") return undefined; + const stored = window.localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY)?.trim(); + return stored || undefined; }; -const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({ - id: session.id, - title: session.title, - createdAt: session.createdAt, - updatedAt: session.updatedAt, -}); - -const getDb = () => - openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, { - upgrade(db) { - if (!db.objectStoreNames.contains(SESSION_STORE)) { - const sessionStore = db.createObjectStore(SESSION_STORE, { - keyPath: "id", - }); - sessionStore.createIndex("by-updatedAt", "updatedAt"); - } - - if (!db.objectStoreNames.contains(META_STORE)) { - db.createObjectStore(META_STORE, { keyPath: "key" }); - } - }, - }); - -const readLegacyChatState = (): LegacyPersistedChatState | null => { - if (typeof window === "undefined") return null; - - try { - const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY); - if (!storedRaw) return null; - - const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState; - if (!Array.isArray(parsed.messages)) { - window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); - return null; - } - - return { - messages: sanitizeMessages(parsed.messages), - sessionId: parsed.sessionId, - branchGroups: sanitizeBranchGroups(parsed.branchGroups), - }; - } catch (error) { - console.error("[GlobalChatbox] Failed to read legacy chat state:", error); - window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); - return null; - } -}; - -const clearLegacyChatState = () => { +const setStoredActiveSessionId = (sessionId?: string) => { if (typeof window === "undefined") return; - window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); + if (sessionId) { + window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, sessionId); + return; + } + window.localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY); }; -const getMeta = async () => { - const db = await getDb(); - return db.get(META_STORE, META_KEY); -}; - -const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => { - const db = await getDb(); - await db.put(META_STORE, { - key: META_KEY, - ...meta, +const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => { + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { + method: "GET", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, }); -}; - -const getLatestSession = async () => { - const db = await getDb(); - const sessions = await db.getAll(SESSION_STORE); - if (sessions.length === 0) return undefined; - return sessions.sort(compareSessionsByAnchorTime)[0]; -}; - -const migrateLegacyLocalStorage = async () => { - const meta = await getMeta(); - if (meta?.migratedFromLocalStorage) return; - - const legacyState = readLegacyChatState(); - if (!legacyState) { - await setMeta({ - activeSessionId: meta?.activeSessionId, - migratedFromLocalStorage: true, - }); - return; + if (!response.ok) { + throw new Error(await response.text()); } - - const hasContent = - legacyState.messages.length > 0 || - (legacyState.branchGroups?.length ?? 0) > 0 || - Boolean(legacyState.sessionId); - - if (!hasContent) { - clearLegacyChatState(); - await setMeta({ - activeSessionId: undefined, - migratedFromLocalStorage: true, - }); - return; - } - - const now = Date.now(); - const sessionRecord: ChatSessionRecord = { - id: createId(), - title: "新对话", - isTitleManuallyEdited: false, - createdAt: now, - updatedAt: now, - sessionId: legacyState.sessionId, - messages: sanitizeMessages(legacyState.messages), - branchGroups: sanitizeBranchGroups(legacyState.branchGroups), + const payload = (await response.json()) as { + sessions?: RemoteSessionPayload[]; }; + return (payload.sessions ?? []) + .map((session) => ({ + id: session.id ?? "", + title: normalizeTitle(session.title), + createdAt: toMillis(session.created_at), + updatedAt: toMillis(session.updated_at), + })) + .filter((session) => Boolean(session.id)) + .sort(compareSessionsByAnchorTime); +}; - const db = await getDb(); - await db.put(SESSION_STORE, sessionRecord); - clearLegacyChatState(); - await setMeta({ - activeSessionId: sessionRecord.id, - migratedFromLocalStorage: true, +const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatState> => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, + { + method: "GET", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok) { + if (response.status === 404) { + return emptyLoadedChatState(); + } + throw new Error(await response.text()); + } + const payload = (await response.json()) as { + id: string; + title?: string; + is_title_manually_edited?: boolean; + session_id?: string; + messages?: Message[]; + branch_groups?: BranchGroup[]; + }; + return { + storageSessionId: payload.id, + title: normalizeTitle(payload.title), + isTitleManuallyEdited: payload.is_title_manually_edited ?? false, + messages: sanitizeMessages(payload.messages), + sessionId: payload.session_id, + branchGroups: sanitizeBranchGroups(payload.branch_groups), + }; +}; + +const createRemoteChatSession = async (payload?: { + sessionId?: string; + parentSessionId?: string; +}) => { + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: payload?.sessionId, + parent_session_id: payload?.parentSessionId, + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, }); + if (!response.ok) { + throw new Error(await response.text()); + } + const body = (await response.json()) as { + session_id?: string; + }; + const sessionId = body.session_id?.trim(); + if (!sessionId) { + throw new Error("backend did not return session_id"); + } + return sessionId; +}; + +const saveRemoteChatState = async ( + sessionId: string, + state: LoadedChatState, +): Promise<string> => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: normalizeTitle(state.title), + is_title_manually_edited: state.isTitleManuallyEdited ?? false, + messages: sanitizeMessages(state.messages), + branch_groups: sanitizeBranchGroups(state.branchGroups), + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } + const payload = (await response.json()) as { id?: string; session_id?: string }; + return payload.id ?? payload.session_id ?? sessionId; +}; + +const updateRemoteChatSessionTitle = async ( + sessionId: string, + title: string, + isTitleManuallyEdited?: boolean, +) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title, + is_title_manually_edited: isTitleManuallyEdited, + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } +}; + +const deleteRemoteChatSession = async (sessionId: string) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, + { + method: "DELETE", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok && response.status !== 404) { + throw new Error(await response.text()); + } }; export const loadActiveChatState = async (): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); - await migrateLegacyLocalStorage(); - - const meta = await getMeta(); - const db = await getDb(); - - if (meta?.activeSessionId) { - const activeSession = await db.get(SESSION_STORE, meta.activeSessionId); - if (activeSession) { - return toLoadedChatState(activeSession); + const activeSessionId = getStoredActiveSessionId(); + if (activeSessionId) { + const activeSession = await fetchRemoteChatSession(activeSessionId); + if (activeSession.storageSessionId) { + return activeSession; } + setStoredActiveSessionId(undefined); } - const latestSession = await getLatestSession(); + const sessions = await fetchRemoteChatSessions(); + const latestSession = sessions[0]; if (!latestSession) { return emptyLoadedChatState(); } - - await setMeta({ - activeSessionId: latestSession.id, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); - - return toLoadedChatState(latestSession); + setStoredActiveSessionId(latestSession.id); + return await fetchRemoteChatSession(latestSession.id); }; export const saveActiveChatState = async ( @@ -252,68 +259,28 @@ export const saveActiveChatState = async ( ): Promise<string | undefined> => { if (typeof window === "undefined") return state.storageSessionId; - const hasContent = hasChatContent(state); - - const db = await getDb(); - const existingSession = state.storageSessionId - ? await db.get(SESSION_STORE, state.storageSessionId) - : undefined; - const meta = await getMeta(); - - if (!hasContent) { - if (state.storageSessionId) { - await db.delete(SESSION_STORE, state.storageSessionId); - } - await setMeta({ - activeSessionId: undefined, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); + if (!hasChatContent(state)) { + setStoredActiveSessionId(undefined); return undefined; } - const now = Date.now(); - const storageSessionId = state.storageSessionId ?? createId(); - const preferredTitle = state.title?.trim(); - const finalTitle = preferredTitle || existingSession?.title || "新对话"; - const hasContentChanged = - !existingSession || - (existingSession && serializeConversationState(existingSession)) !== - serializeConversationState(state); - const shouldAnchorCreatedAtToFirstMessage = - existingSession && !hasChatContent(existingSession) && hasContent; - const nextRecord: ChatSessionRecord = { - id: storageSessionId, - title: finalTitle, - isTitleManuallyEdited: - state.isTitleManuallyEdited ?? - existingSession?.isTitleManuallyEdited ?? - false, - createdAt: shouldAnchorCreatedAtToFirstMessage - ? now - : existingSession?.createdAt ?? now, - updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now, - sessionId: state.sessionId, - messages: sanitizeMessages(state.messages), - branchGroups: sanitizeBranchGroups(state.branchGroups), - }; + let remoteSessionId = state.sessionId ?? state.storageSessionId; + if (!remoteSessionId) { + remoteSessionId = await createRemoteChatSession(); + } - await db.put(SESSION_STORE, nextRecord); - await setMeta({ - activeSessionId: storageSessionId, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + const savedSessionId = await saveRemoteChatState(remoteSessionId, { + ...state, + storageSessionId: remoteSessionId, + sessionId: remoteSessionId, }); - - return storageSessionId; + setStoredActiveSessionId(savedSessionId); + return savedSessionId; }; export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { if (typeof window === "undefined") return []; - - await migrateLegacyLocalStorage(); - - const db = await getDb(); - const sessions = await db.getAll(SESSION_STORE); - return sessions.sort(compareSessionsByAnchorTime).map(toSessionSummary); + return await fetchRemoteChatSessions(); }; export const updateChatSessionTitle = async ( @@ -327,45 +294,21 @@ export const updateChatSessionTitle = async ( const normalizedTitle = title.trim(); if (!normalizedTitle) return; - - const db = await getDb(); - const session = await db.get(SESSION_STORE, storageSessionId); - if (!session) return; - - await db.put(SESSION_STORE, { - ...session, - title: normalizedTitle, - isTitleManuallyEdited: - options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false, - }); + await updateRemoteChatSessionTitle( + storageSessionId, + normalizedTitle, + options?.isTitleManuallyEdited, + ); }; export const createEmptyChatSession = async (): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); - await migrateLegacyLocalStorage(); - - const now = Date.now(); - const session: ChatSessionRecord = { - id: createId(), + setStoredActiveSessionId(undefined); + return { + ...emptyLoadedChatState(), title: "新对话", - isTitleManuallyEdited: false, - createdAt: now, - updatedAt: now, - sessionId: undefined, - messages: [], - branchGroups: [], }; - - const db = await getDb(); - await db.put(SESSION_STORE, session); - const meta = await getMeta(); - await setMeta({ - activeSessionId: session.id, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); - - return toLoadedChatState(session); }; export const loadChatSessionById = async ( @@ -373,21 +316,11 @@ export const loadChatSessionById = async ( ): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); - await migrateLegacyLocalStorage(); - - const db = await getDb(); - const session = await db.get(SESSION_STORE, sessionId); - if (!session) { - return emptyLoadedChatState(); + const loaded = await fetchRemoteChatSession(sessionId); + if (loaded.storageSessionId) { + setStoredActiveSessionId(sessionId); } - - const meta = await getMeta(); - await setMeta({ - activeSessionId: session.id, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); - - return toLoadedChatState(session); + return loaded; }; export const deleteChatSession = async ( @@ -395,19 +328,8 @@ export const deleteChatSession = async ( ): Promise<string | undefined> => { if (typeof window === "undefined") return undefined; - const db = await getDb(); - await db.delete(SESSION_STORE, sessionId); - - const remainingSessions = await db.getAll(SESSION_STORE); - const nextActiveSession = remainingSessions.sort( - compareSessionsByAnchorTime, - )[0]; - const meta = await getMeta(); - - await setMeta({ - activeSessionId: nextActiveSession?.id, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); - + await deleteRemoteChatSession(sessionId); + const nextActiveSession = (await listChatSessions())[0]; + setStoredActiveSessionId(nextActiveSession?.id); return nextActiveSession?.id; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 14f391f..79163da 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; +import { streamAgentChat } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), @@ -12,6 +13,7 @@ jest.mock("@/lib/chatStream", () => ({ const loadActiveChatState = jest.fn(); const listChatSessions = jest.fn(); +const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ deleteChatSession: jest.fn(async () => undefined), @@ -26,13 +28,15 @@ jest.mock("../chatStorage", () => ({ branchGroups: [], })), saveActiveChatState: jest.fn(async (state) => state.storageSessionId), - updateChatSessionTitle: jest.fn(async () => undefined), + updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); describe("useAgentChatSession", () => { beforeEach(() => { loadActiveChatState.mockReset(); listChatSessions.mockReset(); + updateChatSessionTitle.mockReset(); + jest.mocked(streamAgentChat).mockReset(); loadActiveChatState.mockResolvedValue({ storageSessionId: undefined, @@ -98,4 +102,46 @@ describe("useAgentChatSession", () => { }, ]); }); + + it("ignores generated session titles after the title was edited manually", async () => { + listChatSessions.mockResolvedValue([]); + loadActiveChatState.mockResolvedValue({ + storageSessionId: "session-1", + title: "手动标题", + isTitleManuallyEdited: true, + messages: [], + sessionId: "session-1", + branchGroups: [], + }); + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "session_title", + sessionId: "session-1", + title: "自动标题", + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("帮我分析一下"); + }); + + expect(result.current.sessionTitle).toBe("手动标题"); + expect(updateChatSessionTitle).not.toHaveBeenCalledWith( + "session-1", + "自动标题", + expect.anything(), + ); + }); }); -- 2.54.0 From 54fbf15be843058572c6f8ae82ff3830d0ec3b66 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 22 May 2026 11:20:06 +0800 Subject: [PATCH 147/281] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=A1=B9=E7=9B=AE=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 3 + src/components/chat/chatStorage.test.ts | 72 ++++++++++++++----- src/components/chat/chatStorage.ts | 50 +++++++++---- .../chat/hooks/useAgentChatSession.test.tsx | 3 + .../chat/hooks/useAgentChatSession.ts | 53 +++++++++++--- 5 files changed, 138 insertions(+), 43 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 010405a..c49a49e 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -9,6 +9,7 @@ import React, { import { Box, Drawer, alpha, useTheme } from "@mui/material"; import type { AgentModel } from "@/lib/chatStream"; +import { useProjectStore } from "@/store/projectStore"; import { AgentComposer } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; import { AgentHistoryPanel } from "./AgentHistoryPanel"; @@ -32,6 +33,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); const theme = useTheme(); + const currentProjectId = useProjectStore((state) => state.currentProjectId); const { speechState, @@ -74,6 +76,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { removeSession, switchSession, } = useAgentChatSession({ + projectId: currentProjectId, onToolCall: handleToolCall, onBeforeSend: stopListening, getModel: () => selectedModel, diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index e602d3b..4b3f2b7 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -42,6 +42,39 @@ describe("chatStorage backend-only persistence", () => { expect(loaded.title).toBe("已存在会话"); }); + it("loads the active remote session from the current project's storage key", async () => { + window.localStorage.setItem( + "tjwater_agent_active_session_id_v2:project-a", + "chat-project-a", + ); + window.localStorage.setItem( + "tjwater_agent_active_session_id_v2:project-b", + "chat-project-b", + ); + + apiFetch.mockImplementation(async (url: string) => { + if (url.endsWith("/api/v1/agent/chat/session/chat-project-b")) { + return { + ok: true, + json: async () => ({ + id: "chat-project-b", + title: "项目 B 会话", + is_title_manually_edited: false, + session_id: "chat-project-b", + messages: [], + branch_groups: [], + }), + } as Response; + } + throw new Error(`Unexpected request ${url}`); + }); + + const loaded = await loadActiveChatState("project-b"); + + expect(loaded.storageSessionId).toBe("chat-project-b"); + expect(loaded.title).toBe("项目 B 会话"); + }); + it("creates a backend conversation when saving the first non-empty state", async () => { apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { if (url.endsWith("/api/v1/agent/chat/session")) { @@ -67,26 +100,29 @@ describe("chatStorage backend-only persistence", () => { throw new Error(`Unexpected request ${url}`); }); - const savedSessionId = await saveActiveChatState({ - storageSessionId: undefined, - title: "新对话", - isTitleManuallyEdited: false, - messages: [ - { - id: "message-2", - role: "user", - content: "第一条消息", - branchRootId: "message-2", - }, - ], - sessionId: undefined, - branchGroups: [], - }); + const savedSessionId = await saveActiveChatState( + { + storageSessionId: undefined, + title: "新对话", + isTitleManuallyEdited: false, + messages: [ + { + id: "message-2", + role: "user", + content: "第一条消息", + branchRootId: "message-2", + }, + ], + sessionId: undefined, + branchGroups: [], + }, + "project-a", + ); expect(savedSessionId).toBe("chat-new-1"); - expect(window.localStorage.getItem("tjwater_agent_active_session_id_v2")).toBe( - "chat-new-1", - ); + expect( + window.localStorage.getItem("tjwater_agent_active_session_id_v2:project-a"), + ).toBe("chat-new-1"); }); it("does not persist a blank new session before there is chat content", async () => { diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index dd2a974..ca1a9de 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -60,19 +60,32 @@ const toMillis = (value: string | number | undefined) => const normalizeTitle = (value?: string) => value?.trim() || "新对话"; -const getStoredActiveSessionId = () => { +const getActiveSessionStorageKey = (projectId?: string | null) => { + const normalizedProjectId = projectId?.trim(); + return normalizedProjectId + ? `${ACTIVE_SESSION_STORAGE_KEY}:${encodeURIComponent(normalizedProjectId)}` + : ACTIVE_SESSION_STORAGE_KEY; +}; + +const getStoredActiveSessionId = (projectId?: string | null) => { if (typeof window === "undefined") return undefined; - const stored = window.localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY)?.trim(); + const stored = window.localStorage + .getItem(getActiveSessionStorageKey(projectId)) + ?.trim(); return stored || undefined; }; -const setStoredActiveSessionId = (sessionId?: string) => { +const setStoredActiveSessionId = ( + sessionId?: string, + projectId?: string | null, +) => { if (typeof window === "undefined") return; + const storageKey = getActiveSessionStorageKey(projectId); if (sessionId) { - window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, sessionId); + window.localStorage.setItem(storageKey, sessionId); return; } - window.localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY); + window.localStorage.removeItem(storageKey); }; const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => { @@ -233,16 +246,18 @@ const deleteRemoteChatSession = async (sessionId: string) => { } }; -export const loadActiveChatState = async (): Promise<LoadedChatState> => { +export const loadActiveChatState = async ( + projectId?: string | null, +): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); - const activeSessionId = getStoredActiveSessionId(); + const activeSessionId = getStoredActiveSessionId(projectId); if (activeSessionId) { const activeSession = await fetchRemoteChatSession(activeSessionId); if (activeSession.storageSessionId) { return activeSession; } - setStoredActiveSessionId(undefined); + setStoredActiveSessionId(undefined, projectId); } const sessions = await fetchRemoteChatSessions(); @@ -250,17 +265,18 @@ export const loadActiveChatState = async (): Promise<LoadedChatState> => { if (!latestSession) { return emptyLoadedChatState(); } - setStoredActiveSessionId(latestSession.id); + setStoredActiveSessionId(latestSession.id, projectId); return await fetchRemoteChatSession(latestSession.id); }; export const saveActiveChatState = async ( state: LoadedChatState, + projectId?: string | null, ): Promise<string | undefined> => { if (typeof window === "undefined") return state.storageSessionId; if (!hasChatContent(state)) { - setStoredActiveSessionId(undefined); + setStoredActiveSessionId(undefined, projectId); return undefined; } @@ -274,7 +290,7 @@ export const saveActiveChatState = async ( storageSessionId: remoteSessionId, sessionId: remoteSessionId, }); - setStoredActiveSessionId(savedSessionId); + setStoredActiveSessionId(savedSessionId, projectId); return savedSessionId; }; @@ -301,10 +317,12 @@ export const updateChatSessionTitle = async ( ); }; -export const createEmptyChatSession = async (): Promise<LoadedChatState> => { +export const createEmptyChatSession = async ( + projectId?: string | null, +): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); - setStoredActiveSessionId(undefined); + setStoredActiveSessionId(undefined, projectId); return { ...emptyLoadedChatState(), title: "新对话", @@ -313,23 +331,25 @@ export const createEmptyChatSession = async (): Promise<LoadedChatState> => { export const loadChatSessionById = async ( sessionId: string, + projectId?: string | null, ): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); const loaded = await fetchRemoteChatSession(sessionId); if (loaded.storageSessionId) { - setStoredActiveSessionId(sessionId); + setStoredActiveSessionId(sessionId, projectId); } return loaded; }; export const deleteChatSession = async ( sessionId: string, + projectId?: string | null, ): Promise<string | undefined> => { if (typeof window === "undefined") return undefined; await deleteRemoteChatSession(sessionId); const nextActiveSession = (await listChatSessions())[0]; - setStoredActiveSessionId(nextActiveSession?.id); + setStoredActiveSessionId(nextActiveSession?.id, projectId); return nextActiveSession?.id; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 79163da..063ed60 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -53,6 +53,7 @@ describe("useAgentChatSession", () => { const { result } = renderHook(() => useAgentChatSession({ + projectId: "project-1", onToolCall: jest.fn(), }), ); @@ -83,6 +84,7 @@ describe("useAgentChatSession", () => { const { result } = renderHook(() => useAgentChatSession({ + projectId: "project-1", onToolCall: jest.fn(), }), ); @@ -127,6 +129,7 @@ describe("useAgentChatSession", () => { const { result } = renderHook(() => useAgentChatSession({ + projectId: "project-1", onToolCall: jest.fn(), }), ); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 883c349..221ea6b 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -28,6 +28,7 @@ import { } from "../chatStorage"; type UseAgentChatSessionOptions = { + projectId?: string | null; onToolCall: ( event: StreamEvent & { type: "tool_call" }, options: { @@ -145,6 +146,7 @@ const messagesEqual = (left: Message[], right: Message[]) => JSON.stringify(left) === JSON.stringify(right); export const useAgentChatSession = ({ + projectId, onToolCall, onBeforeSend, getModel, @@ -190,9 +192,37 @@ export const useAgentChatSession = ({ let cancelled = false; const hydrate = async () => { + setIsHydrating(true); + hydrationCompletedRef.current = false; + + if (!projectId) { + storageSessionIdRef.current = undefined; + sessionIdRef.current = undefined; + lastPersistedStateKeyRef.current = createPersistedStateKey({ + storageSessionId: undefined, + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + branchGroups: [], + }); + hydrationCompletedRef.current = true; + hydrationNonceRef.current += 1; + titleUpdateNonceRef.current += 1; + setBranchTransition(null); + setMessages([]); + setSessionTitle(undefined); + setIsSessionTitleManuallyEdited(false); + setSessionId(undefined); + setBranchGroups([]); + setChatSessions([]); + setIsHydrating(false); + return; + } + try { const [loadedState, sessions] = await Promise.all([ - loadActiveChatState(), + loadActiveChatState(projectId), listChatSessions(), ]); if (cancelled) return; @@ -224,10 +254,10 @@ export const useAgentChatSession = ({ return () => { cancelled = true; }; - }, []); + }, [projectId]); useEffect(() => { - if (isHydrating || !hydrationCompletedRef.current) return; + if (!projectId || isHydrating || !hydrationCompletedRef.current) return; const currentHydrationNonce = hydrationNonceRef.current; const persistTimer = window.setTimeout(() => { @@ -244,7 +274,7 @@ export const useAgentChatSession = ({ return; } - void saveActiveChatState(state) + void saveActiveChatState(state, projectId) .then((storageSessionId) => { if (hydrationNonceRef.current !== currentHydrationNonce) return; storageSessionIdRef.current = storageSessionId; @@ -266,7 +296,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, sessionId, sessionTitle]); + }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, projectId, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -578,7 +608,7 @@ export const useAgentChatSession = ({ setIsHydrating(true); try { const [nextState, sessions] = await Promise.all([ - loadChatSessionById(nextStorageSessionId), + loadChatSessionById(nextStorageSessionId, projectId), listChatSessions(), ]); @@ -600,7 +630,7 @@ export const useAgentChatSession = ({ setIsHydrating(false); } }, - [isHydrating, isStreaming], + [isHydrating, isStreaming, projectId], ); const removeSession = useCallback( @@ -608,7 +638,10 @@ export const useAgentChatSession = ({ if (isHydrating || isStreaming) return; try { - const nextActiveSessionId = await deleteChatSession(targetStorageSessionId); + const nextActiveSessionId = await deleteChatSession( + targetStorageSessionId, + projectId, + ); const sessions = await listChatSessions(); setChatSessions(sessions); @@ -640,7 +673,7 @@ export const useAgentChatSession = ({ setIsHydrating(true); const [nextState, sessionsAfterDelete] = await Promise.all([ - loadChatSessionById(nextActiveSessionId), + loadChatSessionById(nextActiveSessionId, projectId), listChatSessions(), ]); hydrationNonceRef.current += 1; @@ -661,7 +694,7 @@ export const useAgentChatSession = ({ setIsHydrating(false); } }, - [isHydrating, isStreaming], + [isHydrating, isStreaming, projectId], ); const sendPrompt = useCallback( -- 2.54.0 From 6b447eb398c4091247d63549c69603c8dd15a6d2 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 22 May 2026 14:19:14 +0800 Subject: [PATCH 148/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8F=AF=E8=83=BD=E5=AD=98=E5=82=A8=E4=B8=A4?= =?UTF-8?q?=E6=AC=A1=E7=9A=84bug=EF=BC=9B=E6=9B=B4=E6=94=B9=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=A1=8C=E4=B8=BA=EF=BC=8C=E9=BB=98=E8=AE=A4=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=E6=96=B0=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 16 +++- src/components/chat/chatStorage.test.ts | 64 ++++----------- src/components/chat/chatStorage.ts | 78 ++----------------- .../chat/hooks/useAgentChatSession.test.tsx | 59 +++++++++++++- .../chat/hooks/useAgentChatSession.ts | 49 +++--------- 5 files changed, 103 insertions(+), 163 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index c49a49e..5ecb3e3 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -32,6 +32,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const bottomRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement | null>(null); + const hasResetForOpenRef = useRef(false); const theme = useTheme(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -87,13 +88,22 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }, [messages, isStreaming]); useEffect(() => { - if (!open) return; + if (!open) { + hasResetForOpenRef.current = false; + return; + } + if (hasResetForOpenRef.current || isHydrating) return; + hasResetForOpenRef.current = true; + const timer = window.setTimeout(() => { + createSession(); + setInput(""); + setIsHistoryOpen(false); inputRef.current?.focus(); bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, 0); return () => window.clearTimeout(timer); - }, [open]); + }, [createSession, isHydrating, open]); const handleSend = useCallback(() => { const prompt = input.trim(); @@ -112,7 +122,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); - void createSession(); + createSession(); setInput(""); window.setTimeout(() => { inputRef.current?.focus(); diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index 4b3f2b7..ffbef3d 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -1,5 +1,4 @@ import { - createEmptyChatSession, loadActiveChatState, saveActiveChatState, } from "./chatStorage"; @@ -16,33 +15,22 @@ describe("chatStorage backend-only persistence", () => { apiFetch.mockReset(); }); - it("loads the active remote session when localStorage has an active id", async () => { + it("starts from an empty conversation instead of restoring a stored active id", async () => { window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1"); - apiFetch.mockImplementation(async (url: string) => { - if (url.endsWith("/api/v1/agent/chat/session/chat-active-1")) { - return { - ok: true, - json: async () => ({ - id: "chat-active-1", - title: "已存在会话", - is_title_manually_edited: false, - session_id: "chat-active-1", - messages: [], - branch_groups: [], - }), - } as Response; - } - throw new Error(`Unexpected request ${url}`); - }); - const loaded = await loadActiveChatState(); - expect(loaded.storageSessionId).toBe("chat-active-1"); - expect(loaded.title).toBe("已存在会话"); + expect(loaded).toMatchObject({ + storageSessionId: undefined, + title: undefined, + messages: [], + sessionId: undefined, + branchGroups: [], + }); + expect(apiFetch).not.toHaveBeenCalled(); }); - it("loads the active remote session from the current project's storage key", async () => { + it("starts from an empty conversation when a project has a stored active id", async () => { window.localStorage.setItem( "tjwater_agent_active_session_id_v2:project-a", "chat-project-a", @@ -52,27 +40,12 @@ describe("chatStorage backend-only persistence", () => { "chat-project-b", ); - apiFetch.mockImplementation(async (url: string) => { - if (url.endsWith("/api/v1/agent/chat/session/chat-project-b")) { - return { - ok: true, - json: async () => ({ - id: "chat-project-b", - title: "项目 B 会话", - is_title_manually_edited: false, - session_id: "chat-project-b", - messages: [], - branch_groups: [], - }), - } as Response; - } - throw new Error(`Unexpected request ${url}`); - }); - const loaded = await loadActiveChatState("project-b"); - expect(loaded.storageSessionId).toBe("chat-project-b"); - expect(loaded.title).toBe("项目 B 会话"); + expect(loaded.storageSessionId).toBeUndefined(); + expect(loaded.title).toBeUndefined(); + expect(loaded.messages).toEqual([]); + expect(apiFetch).not.toHaveBeenCalled(); }); it("creates a backend conversation when saving the first non-empty state", async () => { @@ -122,14 +95,7 @@ describe("chatStorage backend-only persistence", () => { expect(savedSessionId).toBe("chat-new-1"); expect( window.localStorage.getItem("tjwater_agent_active_session_id_v2:project-a"), - ).toBe("chat-new-1"); + ).toBeNull(); }); - it("does not persist a blank new session before there is chat content", async () => { - const session = await createEmptyChatSession(); - - expect(session.storageSessionId).toBeUndefined(); - expect(session.title).toBe("新对话"); - expect(apiFetch).not.toHaveBeenCalled(); - }); }); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index ca1a9de..bda1fbc 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -9,8 +9,6 @@ import type { } from "./GlobalChatbox.types"; import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; -const ACTIVE_SESSION_STORAGE_KEY = "tjwater_agent_active_session_id_v2"; - type RemoteSessionPayload = { id?: string; title?: string; @@ -60,34 +58,6 @@ const toMillis = (value: string | number | undefined) => const normalizeTitle = (value?: string) => value?.trim() || "新对话"; -const getActiveSessionStorageKey = (projectId?: string | null) => { - const normalizedProjectId = projectId?.trim(); - return normalizedProjectId - ? `${ACTIVE_SESSION_STORAGE_KEY}:${encodeURIComponent(normalizedProjectId)}` - : ACTIVE_SESSION_STORAGE_KEY; -}; - -const getStoredActiveSessionId = (projectId?: string | null) => { - if (typeof window === "undefined") return undefined; - const stored = window.localStorage - .getItem(getActiveSessionStorageKey(projectId)) - ?.trim(); - return stored || undefined; -}; - -const setStoredActiveSessionId = ( - sessionId?: string, - projectId?: string | null, -) => { - if (typeof window === "undefined") return; - const storageKey = getActiveSessionStorageKey(projectId); - if (sessionId) { - window.localStorage.setItem(storageKey, sessionId); - return; - } - window.localStorage.removeItem(storageKey); -}; - const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { method: "GET", @@ -247,36 +217,18 @@ const deleteRemoteChatSession = async (sessionId: string) => { }; export const loadActiveChatState = async ( - projectId?: string | null, + _projectId?: string | null, ): Promise<LoadedChatState> => { - if (typeof window === "undefined") return emptyLoadedChatState(); - - const activeSessionId = getStoredActiveSessionId(projectId); - if (activeSessionId) { - const activeSession = await fetchRemoteChatSession(activeSessionId); - if (activeSession.storageSessionId) { - return activeSession; - } - setStoredActiveSessionId(undefined, projectId); - } - - const sessions = await fetchRemoteChatSessions(); - const latestSession = sessions[0]; - if (!latestSession) { - return emptyLoadedChatState(); - } - setStoredActiveSessionId(latestSession.id, projectId); - return await fetchRemoteChatSession(latestSession.id); + return emptyLoadedChatState(); }; export const saveActiveChatState = async ( state: LoadedChatState, - projectId?: string | null, + _projectId?: string | null, ): Promise<string | undefined> => { if (typeof window === "undefined") return state.storageSessionId; if (!hasChatContent(state)) { - setStoredActiveSessionId(undefined, projectId); return undefined; } @@ -290,7 +242,6 @@ export const saveActiveChatState = async ( storageSessionId: remoteSessionId, sessionId: remoteSessionId, }); - setStoredActiveSessionId(savedSessionId, projectId); return savedSessionId; }; @@ -317,39 +268,22 @@ export const updateChatSessionTitle = async ( ); }; -export const createEmptyChatSession = async ( - projectId?: string | null, -): Promise<LoadedChatState> => { - if (typeof window === "undefined") return emptyLoadedChatState(); - - setStoredActiveSessionId(undefined, projectId); - return { - ...emptyLoadedChatState(), - title: "新对话", - }; -}; - export const loadChatSessionById = async ( sessionId: string, - projectId?: string | null, + _projectId?: string | null, ): Promise<LoadedChatState> => { if (typeof window === "undefined") return emptyLoadedChatState(); - const loaded = await fetchRemoteChatSession(sessionId); - if (loaded.storageSessionId) { - setStoredActiveSessionId(sessionId, projectId); - } - return loaded; + return await fetchRemoteChatSession(sessionId); }; export const deleteChatSession = async ( sessionId: string, - projectId?: string | null, + _projectId?: string | null, ): Promise<string | undefined> => { if (typeof window === "undefined") return undefined; await deleteRemoteChatSession(sessionId); const nextActiveSession = (await listChatSessions())[0]; - setStoredActiveSessionId(nextActiveSession?.id, projectId); return nextActiveSession?.id; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 063ed60..d321c55 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; import { streamAgentChat } from "@/lib/chatStream"; +import type { StreamEvent } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), @@ -13,6 +14,7 @@ jest.mock("@/lib/chatStream", () => ({ const loadActiveChatState = jest.fn(); const listChatSessions = jest.fn(); +const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ @@ -27,7 +29,7 @@ jest.mock("../chatStorage", () => ({ sessionId: undefined, branchGroups: [], })), - saveActiveChatState: jest.fn(async (state) => state.storageSessionId), + saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); @@ -35,8 +37,10 @@ describe("useAgentChatSession", () => { beforeEach(() => { loadActiveChatState.mockReset(); listChatSessions.mockReset(); + saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(streamAgentChat).mockReset(); + saveActiveChatState.mockImplementation(async (state) => state.storageSessionId); loadActiveChatState.mockResolvedValue({ storageSessionId: undefined, @@ -105,6 +109,59 @@ describe("useAgentChatSession", () => { ]); }); + it("waits for the stream session id before persisting a new streaming conversation", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise<void>(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + jest.useFakeTimers(); + try { + await act(async () => { + void result.current.sendPrompt("第一条消息"); + await Promise.resolve(); + }); + + expect(result.current.isStreaming).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).not.toHaveBeenCalled(); + + act(() => { + emitStreamEvent?.({ + type: "token", + sessionId: "chat-stream-1", + content: "收到", + }); + }); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).toHaveBeenCalledTimes(1); + expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ + sessionId: "chat-stream-1", + }); + } finally { + jest.useRealTimers(); + } + }); + it("ignores generated session titles after the title was edited manually", async () => { listChatSessions.mockResolvedValue([]); loadActiveChatState.mockResolvedValue({ diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 221ea6b..ac0feb8 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -269,6 +269,15 @@ export const useAgentChatSession = ({ sessionId, branchGroups, }; + if ( + isStreaming && + !state.storageSessionId && + !state.sessionId && + state.messages.length > 0 + ) { + return; + } + const currentStateKey = createPersistedStateKey(state); if (currentStateKey === lastPersistedStateKeyRef.current) { return; @@ -296,7 +305,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, messages, projectId, sessionId, sessionTitle]); + }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -538,42 +547,7 @@ export const useAgentChatSession = ({ cancelPromiseRef.current = trackedCancelPromise; }, []); - const reset = useCallback(() => { - const controller = abortRef.current; - controller?.abort(); - const activeSessionId = sessionIdRef.current; - if (activeSessionId) { - const cancelPromise = abortAgentChat(activeSessionId).catch((error) => { - console.error("[GlobalChatbox] Failed to abort agent session during reset:", error); - }); - const trackedCancelPromise = cancelPromise.finally(() => { - if (cancelPromiseRef.current === trackedCancelPromise) { - cancelPromiseRef.current = null; - } - }); - cancelPromiseRef.current = trackedCancelPromise; - } - setMessages([]); - setSessionTitle(undefined); - setIsSessionTitleManuallyEdited(false); - setBranchGroups([]); - setBranchTransition(null); - setSessionId(undefined); - sessionIdRef.current = undefined; - storageSessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - storageSessionId: undefined, - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - branchGroups: [], - }); - titleUpdateNonceRef.current += 1; - setIsStreaming(false); - }, []); - - const createSession = useCallback(async () => { + const createSession = useCallback(() => { if (isHydrating || isStreaming) return; const controller = abortRef.current; @@ -903,7 +877,6 @@ export const useAgentChatSession = ({ cycleBranch, abort, createSession, - reset, renameSession, removeSession, switchSession, -- 2.54.0 From 9dc8549f31a298b8d46fc9e97b80a357157331f8 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 28 May 2026 17:02:38 +0800 Subject: [PATCH 149/281] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B0=B4=E6=B5=81?= =?UTF-8?q?=E3=80=81=E7=AD=89=E5=80=BC=E7=BA=BF=E5=9B=BE=E5=B1=82=E6=98=BE?= =?UTF-8?q?=E7=A4=BAbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/core/Controls/LayerControl.tsx | 42 +++++++++++++++++-- src/components/olmap/core/MapComponent.tsx | 4 ++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/components/olmap/core/Controls/LayerControl.tsx b/src/components/olmap/core/Controls/LayerControl.tsx index 6796d4d..a503e3b 100644 --- a/src/components/olmap/core/Controls/LayerControl.tsx +++ b/src/components/olmap/core/Controls/LayerControl.tsx @@ -37,6 +37,8 @@ const LayerControl: React.FC = () => { const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []); const isContourLayerAvailable = data?.isContourLayerAvailable; const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable; + const showContourLayer = data?.showContourLayer; + const showWaterflowLayer = data?.showWaterflowLayer; const setShowWaterflowLayer = data?.setShowWaterflowLayer; const setShowContourLayer = data?.setShowContourLayer; @@ -46,6 +48,14 @@ const LayerControl: React.FC = () => { if (!map || !data) return []; const items: LayerItem[] = []; + const upsertLayerItem = (nextItem: LayerItem) => { + const index = items.findIndex((item) => item.id === nextItem.id); + if (index >= 0) { + items[index] = nextItem; + return; + } + items.push(nextItem); + }; map.getLayers().getArray().forEach((layer) => { if ( @@ -56,7 +66,7 @@ const LayerControl: React.FC = () => { const value = layer.get("value"); const name = layer.get("name"); if (value) { - items.push({ + upsertLayerItem({ id: value, name: name || value, visible: layer.getVisible(), @@ -80,7 +90,7 @@ const LayerControl: React.FC = () => { return; } - items.push({ + upsertLayerItem({ id: layer.props.id, name: layer.props.name, visible: @@ -91,6 +101,30 @@ const LayerControl: React.FC = () => { }); } + if (isWaterflowLayerAvailable) { + upsertLayerItem({ + id: "waterflowLayer", + name: "水流", + visible: + deckLayer?.getDeckLayerVisible("waterflowLayer") ?? showWaterflowLayer ?? false, + type: "deck", + layerRef: deckLayer?.getDeckLayerById("waterflowLayer") ?? null, + }); + } + + if (isContourLayerAvailable) { + upsertLayerItem({ + id: "junctionContourLayer", + name: "等值线", + visible: + deckLayer?.getDeckLayerVisible("junctionContourLayer") ?? + showContourLayer ?? + false, + type: "deck", + layerRef: deckLayer?.getDeckLayerById("junctionContourLayer") ?? null, + }); + } + return items .filter((item) => LAYER_ORDER.includes(item.id)) .sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id)); @@ -100,6 +134,8 @@ const LayerControl: React.FC = () => { deckLayer, isContourLayerAvailable, isWaterflowLayerAvailable, + showContourLayer, + showWaterflowLayer, refreshKey, ]); @@ -126,7 +162,7 @@ const LayerControl: React.FC = () => { .filter((layer) => layer.get("value") === item.id) .forEach((layer) => layer.setVisible(checked)); }); - } else if (item.type === "deck" && deckLayers.length > 0) { + } else if (item.type === "deck") { deckLayers.forEach((targetDeckLayer) => { targetDeckLayer.setDeckLayerVisible(item.id, checked); }); diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 94d76e2..64ad653 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -65,8 +65,10 @@ interface DataContextType { setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>; setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>; setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>; + showContourLayer?: boolean; setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>; isContourLayerAvailable?: boolean; + showWaterflowLayer?: boolean; setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>; setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>; isWaterflowLayerAvailable?: boolean; @@ -1504,8 +1506,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { setShowPipeId, showJunctionId, showPipeId, + showContourLayer, setShowContourLayer, isContourLayerAvailable, + showWaterflowLayer, setContourLayerAvailable, isWaterflowLayerAvailable, setWaterflowLayerAvailable, -- 2.54.0 From a4f0ffcd3237b8d614f0ea3491ad2acbc0e0aa81 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 28 May 2026 17:11:08 +0800 Subject: [PATCH 150/281] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=BD=B4=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/HealthRiskAnalysis/Timeline.tsx | 25 ++++++++++++++++++- .../olmap/core/Controls/Timeline.tsx | 25 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index 9d8742b..9f31964 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -59,6 +59,23 @@ interface TimelineProps { schemeName?: string; } +const timelineIconButtonSx = { + width: 32, + height: 32, + borderRadius: "50%", + flexShrink: 0, + overflow: "hidden", + "&:hover": { + borderRadius: "50%", + }, + "&.Mui-focusVisible": { + borderRadius: "50%", + }, + "& .MuiTouchRipple-root": { + borderRadius: "50%", + }, +} as const; + const Timeline: React.FC<TimelineProps> = ({ disableDateSelection = false, }) => { @@ -445,7 +462,7 @@ const Timeline: React.FC<TimelineProps> = ({ }; return ( - <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300"> + <div className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn" @@ -481,6 +498,7 @@ const Timeline: React.FC<TimelineProps> = ({ onClick={handleDayStepBackward} size="small" disabled={disableDateSelection} + sx={timelineIconButtonSx} > <FiSkipBack /> </IconButton> @@ -517,6 +535,7 @@ const Timeline: React.FC<TimelineProps> = ({ selectedDateTime.toDateString() === new Date().toDateString() } + sx={timelineIconButtonSx} > <FiSkipForward /> </IconButton> @@ -545,6 +564,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="primary" onClick={handleStepBackward} size="small" + sx={timelineIconButtonSx} > <TbArrowBackUp /> </IconButton> @@ -555,6 +575,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="primary" onClick={isPlaying ? handlePause : handlePlay} size="small" + sx={timelineIconButtonSx} > {isPlaying ? <Pause /> : <PlayArrow />} </IconButton> @@ -565,6 +586,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="primary" onClick={handleStepForward} size="small" + sx={timelineIconButtonSx} > <TbArrowForwardUp /> </IconButton> @@ -575,6 +597,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="secondary" onClick={handleStop} size="small" + sx={timelineIconButtonSx} > <Stop /> </IconButton> diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index 3fe54da..6bfbfce 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -39,6 +39,23 @@ interface TimelineProps { schemeType?: string; } +const timelineIconButtonSx = { + width: 32, + height: 32, + borderRadius: "50%", + flexShrink: 0, + overflow: "hidden", + "&:hover": { + borderRadius: "50%", + }, + "&.Mui-focusVisible": { + borderRadius: "50%", + }, + "& .MuiTouchRipple-root": { + borderRadius: "50%", + }, +} as const; + const NOOP_SET_CURRENT_TIME = (_: any) => undefined; const NOOP_SET_SELECTED_DATE = (_: any) => undefined; @@ -665,7 +682,7 @@ const Timeline: React.FC<TimelineProps> = ({ <Draggable nodeRef={draggableRef} handle=".drag-handle"> <div ref={draggableRef} - className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300" + className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100" > <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <Paper @@ -723,6 +740,7 @@ const Timeline: React.FC<TimelineProps> = ({ onClick={handleDayStepBackward} size="small" disabled={disableDateSelection} + sx={timelineIconButtonSx} > <FiSkipBack /> </IconButton> @@ -757,6 +775,7 @@ const Timeline: React.FC<TimelineProps> = ({ selectedDate.toDateString() === new Date().toDateString() } + sx={timelineIconButtonSx} > <FiSkipForward /> </IconButton> @@ -785,6 +804,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="primary" onClick={handleStepBackward} size="small" + sx={timelineIconButtonSx} > <TbRewindBackward15 /> </IconButton> @@ -795,6 +815,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="primary" onClick={isPlaying ? handlePause : handlePlay} size="small" + sx={timelineIconButtonSx} > {isPlaying ? <Pause /> : <PlayArrow />} </IconButton> @@ -805,6 +826,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="primary" onClick={handleStepForward} size="small" + sx={timelineIconButtonSx} > <TbRewindForward15 /> </IconButton> @@ -815,6 +837,7 @@ const Timeline: React.FC<TimelineProps> = ({ color="secondary" onClick={handleStop} size="small" + sx={timelineIconButtonSx} > <Stop /> </IconButton> -- 2.54.0 From 0e82c080df1bc63c3708b71b57ed3a9d982b1925 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 29 May 2026 10:02:26 +0800 Subject: [PATCH 151/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=A0=B7=E5=BC=8F=E5=8A=9F=E8=83=BD=EF=BC=9B?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E8=AE=A1=E7=AE=97=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E9=BB=98=E8=AE=A4=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/core/Controls/StyleEditorForm.tsx | 590 +++++ .../olmap/core/Controls/StyleEditorPanel.tsx | 1968 +---------------- .../olmap/core/Controls/Timeline.tsx | 2 + .../olmap/core/Controls/Toolbar.tsx | 81 +- .../olmap/core/Controls/styleEditorPresets.ts | 200 ++ .../olmap/core/Controls/styleEditorTypes.ts | 62 + .../olmap/core/Controls/styleEditorUtils.ts | 348 +++ .../olmap/core/Controls/useStyleEditor.ts | 944 ++++++++ src/components/olmap/core/MapComponent.tsx | 8 +- 9 files changed, 2201 insertions(+), 2002 deletions(-) create mode 100644 src/components/olmap/core/Controls/StyleEditorForm.tsx create mode 100644 src/components/olmap/core/Controls/styleEditorPresets.ts create mode 100644 src/components/olmap/core/Controls/styleEditorTypes.ts create mode 100644 src/components/olmap/core/Controls/styleEditorUtils.ts create mode 100644 src/components/olmap/core/Controls/useStyleEditor.ts diff --git a/src/components/olmap/core/Controls/StyleEditorForm.tsx b/src/components/olmap/core/Controls/StyleEditorForm.tsx new file mode 100644 index 0000000..eabf1ea --- /dev/null +++ b/src/components/olmap/core/Controls/StyleEditorForm.tsx @@ -0,0 +1,590 @@ +import ApplyIcon from "@mui/icons-material/Check"; +import ColorLensIcon from "@mui/icons-material/ColorLens"; +import ResetIcon from "@mui/icons-material/Refresh"; +import { + Box, + Button, + Checkbox, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Slider, + TextField, + Typography, +} from "@mui/material"; +import React from "react"; + +import { + CLASSIFICATION_METHODS, + COLOR_TYPE_OPTIONS, + GRADIENT_PALETTES, + RAINBOW_PALETTES, + SINGLE_COLOR_PALETTES, +} from "./styleEditorPresets"; +import { StyleEditorFormProps } from "./styleEditorTypes"; +import { + getSizePreviewColors, + hexToRgba, + resolveStyleColors, + rgbaToHex, +} from "./styleEditorUtils"; + +const StyleEditorForm: React.FC<StyleEditorFormProps> = ({ + renderLayers, + selectedRenderLayer, + styleConfig, + setStyleConfig, + availableProperties, + onLayerChange, + onPropertyChange, + onClassificationMethodChange, + onSegmentsChange, + onCustomBreakChange, + onCustomBreakBlur, + onColorTypeChange, + onApply, + onReset, +}) => { + const renderColorSetting = () => { + if (styleConfig.colorType === "single") { + return ( + <FormControl variant="standard" fullWidth margin="dense" className="mt-3"> + <InputLabel>单一色方案</InputLabel> + <Select + value={styleConfig.singlePaletteIndex} + onChange={(e) => + setStyleConfig((prev) => ({ + ...prev, + singlePaletteIndex: Number(e.target.value), + })) + } + > + {SINGLE_COLOR_PALETTES.map((palette, index) => ( + <MenuItem key={index} value={index}> + <Box width="100%" sx={{ display: "flex", alignItems: "center" }}> + <Box + sx={{ + width: "80%", + height: 16, + borderRadius: 2, + background: palette.color, + marginRight: 1, + border: "1px solid #ccc", + }} + /> + </Box> + </MenuItem> + ))} + </Select> + </FormControl> + ); + } + + if (styleConfig.colorType === "gradient") { + return ( + <FormControl variant="standard" fullWidth margin="dense" className="mt-3"> + <InputLabel>渐进色方案</InputLabel> + <Select + value={styleConfig.gradientPaletteIndex} + onChange={(e) => + setStyleConfig((prev) => ({ + ...prev, + gradientPaletteIndex: Number(e.target.value), + })) + } + > + {GRADIENT_PALETTES.map((palette, index) => { + const previewColors = resolveStyleColors( + { ...styleConfig, colorType: "gradient", gradientPaletteIndex: index }, + styleConfig.segments + 1 + ); + return ( + <MenuItem key={index} value={index}> + <Box width="100%" sx={{ display: "flex", alignItems: "center" }}> + <Box + sx={{ + width: "80%", + height: 16, + borderRadius: 2, + display: "flex", + overflow: "hidden", + marginRight: 1, + border: "1px solid #ccc", + }} + > + {previewColors.map((color, colorIndex) => ( + <Box + key={colorIndex} + sx={{ flex: 1, backgroundColor: color }} + /> + ))} + </Box> + </Box> + </MenuItem> + ); + })} + </Select> + </FormControl> + ); + } + + if (styleConfig.colorType === "rainbow") { + return ( + <FormControl variant="standard" fullWidth margin="dense" className="mt-3"> + <InputLabel>离散彩虹方案</InputLabel> + <Select + value={styleConfig.rainbowPaletteIndex} + onChange={(e) => + setStyleConfig((prev) => ({ + ...prev, + rainbowPaletteIndex: Number(e.target.value), + })) + } + > + {RAINBOW_PALETTES.map((palette, index) => { + const previewColors = Array.from( + { length: styleConfig.segments + 1 }, + (_, colorIndex) => palette.colors[colorIndex % palette.colors.length] + ); + return ( + <MenuItem key={index} value={index}> + <Box width="100%" sx={{ display: "flex", alignItems: "center" }}> + <Typography sx={{ marginRight: 1 }}>{palette.name}</Typography> + <Box + sx={{ + width: "60%", + height: 16, + borderRadius: 2, + display: "flex", + border: "1px solid #ccc", + overflow: "hidden", + }} + > + {previewColors.map((color, colorIndex) => ( + <Box + key={colorIndex} + sx={{ flex: 1, backgroundColor: color }} + /> + ))} + </Box> + </Box> + </MenuItem> + ); + })} + </Select> + </FormControl> + ); + } + + if (styleConfig.colorType === "custom") { + return ( + <Box className="mt-3"> + <Typography variant="subtitle2" gutterBottom> + 自定义颜色 + </Typography> + <Box + className="flex flex-col gap-2" + sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }} + > + {Array.from({ length: styleConfig.segments }).map((_, index) => { + const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)"; + return ( + <Box key={index} className="flex items-center gap-2"> + <Typography variant="caption" sx={{ width: 40 }}> + 分段{index + 1} + </Typography> + <input + type="color" + value={rgbaToHex(color)} + onChange={(e) => { + const nextColor = hexToRgba(e.target.value); + setStyleConfig((prev) => { + const nextColors = [...(prev.customColors || [])]; + while (nextColors.length < prev.segments) { + nextColors.push("rgba(0,0,0,1)"); + } + nextColors[index] = nextColor; + return { ...prev, customColors: nextColors }; + }); + }} + style={{ + width: "100%", + height: "32px", + cursor: "pointer", + border: "1px solid #ccc", + borderRadius: "4px", + }} + /> + </Box> + ); + })} + </Box> + </Box> + ); + } + + return null; + }; + + const renderSizeSetting = () => { + const previewColors = getSizePreviewColors(styleConfig); + + if (selectedRenderLayer?.get("type") === "point") { + return ( + <Box className="mt-3"> + <Typography gutterBottom> + 点大小范围: {styleConfig.minSize} - {styleConfig.maxSize} 像素 + </Typography> + <Box className="flex items-center gap-4"> + <Box className="flex-1"> + <Typography variant="caption" gutterBottom> + 最小值 + </Typography> + <Slider + value={styleConfig.minSize} + onChange={(_, value) => + setStyleConfig((prev) => ({ ...prev, minSize: value as number })) + } + min={2} + max={8} + step={1} + size="small" + /> + </Box> + <Box className="flex-1"> + <Typography variant="caption" gutterBottom> + 最大值 + </Typography> + <Slider + value={styleConfig.maxSize} + onChange={(_, value) => + setStyleConfig((prev) => ({ ...prev, maxSize: value as number })) + } + min={10} + max={16} + step={1} + size="small" + /> + </Box> + </Box> + <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> + <Typography variant="caption">预览:</Typography> + <Box + sx={{ + width: styleConfig.minSize, + height: styleConfig.minSize, + borderRadius: "50%", + backgroundColor: previewColors[0], + }} + /> + <Typography variant="caption">到</Typography> + <Box + sx={{ + width: styleConfig.maxSize, + height: styleConfig.maxSize, + borderRadius: "50%", + backgroundColor: previewColors[previewColors.length - 1], + }} + /> + </Box> + </Box> + ); + } + + if (selectedRenderLayer?.get("type") === "linestring") { + return ( + <Box className="mt-3"> + <FormControlLabel + control={ + <Checkbox + checked={styleConfig.adjustWidthByProperty} + onChange={(e) => + setStyleConfig((prev) => ({ + ...prev, + adjustWidthByProperty: e.target.checked, + })) + } + disabled={styleConfig.colorType === "single"} + /> + } + label="根据数值分段调整线条宽度" + /> + {styleConfig.adjustWidthByProperty ? ( + <> + <Typography gutterBottom> + 线条宽度范围: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth} + px + </Typography> + <Box className="flex items-center gap-4"> + <Box className="flex-1"> + <Typography variant="caption" gutterBottom> + 最小值 + </Typography> + <Slider + value={styleConfig.minStrokeWidth} + onChange={(_, value) => + setStyleConfig((prev) => ({ + ...prev, + minStrokeWidth: value as number, + })) + } + min={1} + max={4} + step={0.5} + size="small" + /> + </Box> + <Box className="flex-1"> + <Typography variant="caption" gutterBottom> + 最大值 + </Typography> + <Slider + value={styleConfig.maxStrokeWidth} + onChange={(_, value) => + setStyleConfig((prev) => ({ + ...prev, + maxStrokeWidth: value as number, + })) + } + min={6} + max={12} + step={0.5} + size="small" + /> + </Box> + </Box> + <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> + <Typography variant="caption">预览:</Typography> + <Box + sx={{ + width: 50, + height: styleConfig.minStrokeWidth, + backgroundColor: previewColors[0], + border: `1px solid ${previewColors[0]}`, + borderRadius: 1, + }} + /> + <Typography variant="caption">到</Typography> + <Box + sx={{ + width: 50, + height: styleConfig.maxStrokeWidth, + backgroundColor: previewColors[previewColors.length - 1], + border: `1px solid ${previewColors[previewColors.length - 1]}`, + borderRadius: 1, + }} + /> + </Box> + </> + ) : ( + <> + <Typography gutterBottom> + 固定线条宽度: {styleConfig.fixedStrokeWidth}px + </Typography> + <Slider + value={styleConfig.fixedStrokeWidth} + onChange={(_, value) => + setStyleConfig((prev) => ({ + ...prev, + fixedStrokeWidth: value as number, + })) + } + min={1} + max={10} + step={0.5} + size="small" + /> + <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> + <Typography variant="caption">预览:</Typography> + <Box + sx={{ + width: 50, + height: styleConfig.fixedStrokeWidth, + backgroundColor: previewColors[0], + border: `1px solid ${previewColors[0]}`, + borderRadius: 1, + }} + /> + </Box> + </> + )} + </Box> + ); + } + + return null; + }; + + return ( + <div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300"> + <FormControl variant="standard" fullWidth margin="dense"> + <InputLabel>选择图层</InputLabel> + <Select + value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""} + onChange={(e) => onLayerChange(e.target.value as number)} + > + {renderLayers.map((layer, index) => ( + <MenuItem key={index} value={index}> + {layer.get("name")} + </MenuItem> + ))} + </Select> + </FormControl> + + <FormControl variant="standard" fullWidth margin="dense"> + <InputLabel>分级属性</InputLabel> + <Select + value={styleConfig.property} + onChange={(e) => onPropertyChange(e.target.value)} + disabled={!selectedRenderLayer} + > + {availableProperties.map((property) => ( + <MenuItem key={property.name} value={property.value}> + {property.name} + </MenuItem> + ))} + </Select> + </FormControl> + + <FormControl variant="standard" fullWidth margin="dense"> + <InputLabel>分类方法</InputLabel> + <Select + value={styleConfig.classificationMethod} + onChange={(e) => onClassificationMethodChange(e.target.value)} + > + {CLASSIFICATION_METHODS.map((method) => ( + <MenuItem key={method.value} value={method.value}> + {method.name} + </MenuItem> + ))} + </Select> + </FormControl> + + <Box className="mt-3"> + <Typography gutterBottom>分类数量: {styleConfig.segments}</Typography> + <Slider + value={styleConfig.segments} + onChange={(_, value) => onSegmentsChange(value as number)} + min={2} + max={10} + step={1} + marks + size="small" + /> + </Box> + + {styleConfig.classificationMethod === "custom_breaks" && ( + <Box className="mt-3 p-2 bg-gray-50 rounded"> + <Typography variant="subtitle2" gutterBottom> + 手动设置区间阈值(按升序填写,最小值 {">="} 0) + </Typography> + <Box + className="flex flex-col gap-2" + sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }} + > + {Array.from({ length: styleConfig.segments }).map((_, index) => ( + <TextField + key={index} + label={`阈值 ${index + 1}`} + type="number" + size="small" + slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }} + value={styleConfig.customBreaks?.[index] ?? ""} + onChange={(e) => onCustomBreakChange(index, e.target.value)} + onBlur={onCustomBreakBlur} + /> + ))} + </Box> + </Box> + )} + + <FormControl variant="standard" fullWidth margin="dense"> + <InputLabel> + <ColorLensIcon className="mr-1" /> + 颜色方案 + </InputLabel> + <Select + value={styleConfig.colorType} + onChange={(e) => onColorTypeChange(e.target.value)} + > + {COLOR_TYPE_OPTIONS.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + {renderColorSetting()} + </FormControl> + + {renderSizeSetting()} + + <Box className="mt-3"> + <Typography gutterBottom> + 透明度: {(styleConfig.opacity * 100).toFixed(0)}% + </Typography> + <Slider + value={styleConfig.opacity} + onChange={(_, value) => + setStyleConfig((prev) => ({ ...prev, opacity: value as number })) + } + min={0.1} + max={1} + step={0.05} + size="small" + /> + </Box> + + <FormControlLabel + control={ + <Checkbox + checked={styleConfig.showId} + onChange={(e) => + setStyleConfig((prev) => ({ ...prev, showId: e.target.checked })) + } + /> + } + label="显示 ID(缩放 >=15 级时显示)" + /> + + <FormControlLabel + control={ + <Checkbox + checked={styleConfig.showLabels} + onChange={(e) => + setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked })) + } + /> + } + label="显示属性(缩放 >=15 级时显示)" + /> + + <div className="my-3"></div> + + <Box className="flex gap-2"> + <Button + variant="contained" + color="primary" + onClick={onApply} + disabled={!selectedRenderLayer || !styleConfig.property} + startIcon={<ApplyIcon />} + fullWidth + > + 应用 + </Button> + <Button + variant="outlined" + onClick={onReset} + disabled={!selectedRenderLayer} + startIcon={<ResetIcon />} + fullWidth + > + 重置 + </Button> + </Box> + </div> + ); +}; + +export default StyleEditorForm; diff --git a/src/components/olmap/core/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx index 8336655..946cd3a 100644 --- a/src/components/olmap/core/Controls/StyleEditorPanel.tsx +++ b/src/components/olmap/core/Controls/StyleEditorPanel.tsx @@ -1,1941 +1,59 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import React from "react"; -// 导入Material-UI图标和组件 -import ColorLensIcon from "@mui/icons-material/ColorLens"; -import ApplyIcon from "@mui/icons-material/Check"; -import ResetIcon from "@mui/icons-material/Refresh"; -import { - Select, - MenuItem, - FormControl, - InputLabel, - Slider, - Typography, - Button, - TextField, - Box, - Checkbox, - FormControlLabel, -} from "@mui/material"; - -// 导入OpenLayers样式相关模块 -import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; -import VectorTileSource from "ol/source/VectorTile"; -import { useData, useMap } from "../MapComponent"; - -import { LegendStyleConfig } from "./StyleLegend"; -import { FlatStyleLike } from "ol/style/flat"; - -import { calculateClassification } from "@utils/breaks_classification"; -import { parseColor } from "@utils/parseColor"; -import { VectorTile } from "ol"; -import type { Map as OlMap } from "ol"; -import { useNotification } from "@refinedev/core"; -import { config } from "@/config/config"; - -interface StyleConfig { - property: string; - classificationMethod: string; // 分类方法 - segments: number; - minSize: number; // 最小点尺寸 - maxSize: number; // 最大点尺寸 - minStrokeWidth: number; // 最小线宽 - maxStrokeWidth: number; // 最大线宽 - fixedStrokeWidth: number; // 固定线宽 - colorType: string; // 颜色类型 - singlePaletteIndex: number; - gradientPaletteIndex: number; - rainbowPaletteIndex: number; - showLabels: boolean; - showId: boolean; - opacity: number; - adjustWidthByProperty: boolean; // 是否根据属性调整线条宽度 - customBreaks?: number[]; // 自定义断点(用于 custom_breaks) - customColors?: string[]; // 自定义颜色(用于 colorType="custom") -} - -// 图层样式状态接口 -export interface LayerStyleState { - layerId: string; - layerName: string; - styleConfig: StyleConfig; - legendConfig: LegendStyleConfig; - isActive: boolean; -} - -// StyleEditorPanel 组件 Props 接口 -interface StyleEditorPanelProps { - layerStyleStates: LayerStyleState[]; - setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>; -} - -// 预设颜色方案 -const SINGLE_COLOR_PALETTES = [ - { - color: "rgba(51, 153, 204, 1)", - }, - { - color: "rgba(255, 138, 92, 1)", - }, - { - color: "rgba(204, 51, 51, 1)", - }, - { - color: "rgba(255, 235, 59, 1)", - }, - { - color: "rgba(44, 160, 44, 1)", - }, - { - color: "rgba(227, 119, 194, 1)", - }, - { - color: "rgba(148, 103, 189, 1)", - }, -]; -const GRADIENT_PALETTES = [ - { - name: "蓝-红", - start: "rgba(51, 153, 204, 1)", - end: "rgba(204, 51, 51, 1)", - }, - { - name: "黄-绿", - start: "rgba(255, 235, 59, 1)", - end: "rgba(44, 160, 44, 1)", - }, - { - name: "粉-紫", - start: "rgba(227, 119, 194, 1)", - end: "rgba(148, 103, 189, 1)", - }, -]; -// 离散彩虹色系 - 提供高区分度的颜色 -const RAINBOW_PALETTES = [ - { - name: "正向彩虹", - colors: [ - "rgba(255, 0, 0, 1)", // 红 #FF0000 - "rgba(255, 127, 0, 1)", // 橙 #FF7F00 - "rgba(255, 215, 0, 1)", // 金黄 #FFD700 - "rgba(199, 224, 0, 1)", // 黄绿 #C7E000 - "rgba(76, 175, 80, 1)", // 中绿 #4CAF50 - "rgba(0, 158, 115, 1)", // 青绿/翡翠 #009E73 - "rgba(0, 188, 212, 1)", // 青/青色 #00BCD4 - "rgba(33, 150, 243, 1)", // 天蓝 #2196F3 - "rgba(63, 81, 181, 1)", // 靛青 #3F51B5 - "rgba(142, 68, 173, 1)", // 紫 #8E44AD - ], - }, - { - name: "反向彩虹", - colors: [ - "rgba(142, 68, 173, 1)", // 紫 #8E44AD - "rgba(63, 81, 181, 1)", // 靛青 #3F51B5 - "rgba(33, 150, 243, 1)", // 天蓝 #2196F3 - "rgba(0, 188, 212, 1)", // 青/青色 #00BCD4 - "rgba(0, 158, 115, 1)", // 青绿/翡翠 #009E73 - "rgba(76, 175, 80, 1)", // 中绿 #4CAF50 - "rgba(199, 224, 0, 1)", // 黄绿 #C7E000 - "rgba(255, 215, 0, 1)", // 金黄 #FFD700 - "rgba(255, 127, 0, 1)", // 橙 #FF7F00 - "rgba(255, 0, 0, 1)", // 红 #FF0000 - ], - }, -]; - -// 预设分类方法 -const CLASSIFICATION_METHODS = [ - { name: "优雅分段", value: "pretty_breaks" }, - // 浏览器中实现Jenks算法性能较差,暂时移除 - // { name: "自然间断", value: "jenks_optimized" }, - { name: "自定义", value: "custom_breaks" }, -]; - -const rgbaToHex = (rgba: string) => { - try { - const c = parseColor(rgba); - const toHex = (n: number) => { - const hex = Math.round(n).toString(16); - return hex.length === 1 ? "0" + hex : hex; - }; - return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`; - } catch (e) { - return "#000000"; - } -}; - -const hexToRgba = (hex: string) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt( - result[3], - 16 - )}, 1)` - : "rgba(0, 0, 0, 1)"; -}; +import StyleEditorForm from "./StyleEditorForm"; +import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets"; +import { useStyleEditor } from "./useStyleEditor"; +import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes"; const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ layerStyleStates, setLayerStyleStates, }) => { - const map = useMap(); - const data = useData(); - const currentJunctionCalData = data?.currentJunctionCalData; - const currentPipeCalData = data?.currentPipeCalData; - const compareJunctionCalData = data?.compareJunctionCalData; - const comparePipeCalData = data?.comparePipeCalData; - const compareMap = data?.compareMap; - const activeMaps = useMemo<OlMap[]>( - () => (data?.maps?.length ? data.maps : map ? [map] : []), - [data?.maps, map] - ); - const junctionText = data?.junctionText ?? ""; - const pipeText = data?.pipeText ?? ""; - const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; - const setShowPipeTextLayer = data?.setShowPipeTextLayer; - const setShowJunctionId = data?.setShowJunctionId; - const setShowPipeId = data?.setShowPipeId; - const setContourLayerAvailable = data?.setContourLayerAvailable; - const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable; - const setJunctionText = data?.setJunctionText; - const setPipeText = data?.setPipeText; - const setContours = data?.setContours; - const diameterRange = data?.diameterRange; - const elevationRange = data?.elevationRange; - - const unitHeadlossRange = [0, 5]; - - const { open } = useNotification(); - - const [applyJunctionStyle, setApplyJunctionStyle] = useState(false); - const [applyPipeStyle, setApplyPipeStyle] = useState(false); - const [styleUpdateTrigger, setStyleUpdateTrigger] = useState(0); // 用于触发样式更新的状态 - const prevStyleUpdateTriggerRef = useRef<number>(0); - - const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]); - const [selectedRenderLayer, setSelectedRenderLayer] = - useState<WebGLVectorTileLayer>(); - const [styleConfig, setStyleConfig] = useState<StyleConfig>({ - property: "", - classificationMethod: "pretty_breaks", - segments: 5, - minSize: 4, - maxSize: 12, - minStrokeWidth: 2, - maxStrokeWidth: 6, - fixedStrokeWidth: 3, - colorType: "single", - singlePaletteIndex: 0, - gradientPaletteIndex: 0, - rainbowPaletteIndex: 0, - showLabels: false, - showId: false, - opacity: 0.9, - adjustWidthByProperty: true, - customBreaks: [], - customColors: [], + const { + isReady, + renderLayers, + selectedRenderLayer, + styleConfig, + setStyleConfig, + availableProperties, + handleLayerChange, + handlePropertyChange, + handleClassificationMethodChange, + handleSegmentsChange, + handleCustomBreakChange, + handleCustomBreakBlur, + handleColorTypeChange, + handleApply, + handleReset, + } = useStyleEditor({ + layerStyleStates, + setLayerStyleStates, }); - const getRenderLayersById = useCallback( - (layerId: string) => - activeMaps.flatMap((targetMap) => - targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .filter((layer): layer is WebGLVectorTileLayer => layer instanceof WebGLVectorTileLayer) - ), - [activeMaps] - ); - - const getMapKey = useCallback((targetMap: OlMap, layerId: string) => { - const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map"; - return `${mapUid}:${layerId}`; - }, []); - - const getDataForMap = useCallback( - (targetMap: OlMap, layerId: string) => { - if (layerId === "junctions") { - return targetMap === compareMap - ? compareJunctionCalData || [] - : currentJunctionCalData || []; - } - if (layerId === "pipes") { - return targetMap === compareMap - ? comparePipeCalData || [] - : currentPipeCalData || []; - } - return []; - }, - [ - compareJunctionCalData, - compareMap, - comparePipeCalData, - currentJunctionCalData, - currentPipeCalData, - ] - ); - - const getDefaultCustomColors = ( - segments: number, - existingColors: string[] = [] - ) => { - const nextColors = [...existingColors]; - const baseColors = RAINBOW_PALETTES[0].colors; - - while (nextColors.length < segments) { - nextColors.push(baseColors[nextColors.length % baseColors.length]); - } - - return nextColors.slice(0, segments); - }; - - const getDefaultCustomBreaks = ( - segments: number, - property: string, - layer: WebGLVectorTileLayer | undefined = selectedRenderLayer - ) => { - if (!layer || !property) { - return Array.from({ length: segments }, () => 0); - } - - const selectedLayerId = layer.get("value"); - let dataArr: number[] = []; - - const isElevation = - selectedLayerId === "junctions" && property === "elevation"; - const isDiameter = selectedLayerId === "pipes" && property === "diameter"; - - if (isElevation && elevationRange) { - dataArr = [elevationRange[0], elevationRange[1]]; - } else if (isDiameter && diameterRange) { - dataArr = [diameterRange[0], diameterRange[1]]; - } else if (selectedLayerId === "junctions" && currentJunctionCalData) { - dataArr = currentJunctionCalData.map((d: any) => d.value); - } else if (selectedLayerId === "pipes" && currentPipeCalData) { - dataArr = currentPipeCalData.map((d: any) => d.value); - } - - if (dataArr.length === 0) { - return Array.from({ length: segments }, () => 0); - } - - const defaultBreaks = calculateClassification( - dataArr, - segments, - "pretty_breaks" - ).slice(0, segments); - - while (defaultBreaks.length < segments) { - defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0); - } - - return defaultBreaks; - }; - - const availableProperties = useMemo<{ name: string; value: string }[]>(() => { - if (!selectedRenderLayer) { - return []; - } - - return (selectedRenderLayer.get("properties") || []) as { - name: string; - value: string; - }[]; - }, [selectedRenderLayer]); - - // 根据分段数生成相应数量的渐进颜色 - const generateGradientColors = useCallback( - (segments: number): string[] => { - const { start, end } = - GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; - const colors: string[] = []; - const startColor = parseColor(start); - const endColor = parseColor(end); - - for (let i = 0; i < segments; i++) { - const ratio = segments > 1 ? i / (segments - 1) : 1; - const r = Math.round( - startColor.r + (endColor.r - startColor.r) * ratio - ); - const g = Math.round( - startColor.g + (endColor.g - startColor.g) * ratio - ); - const b = Math.round( - startColor.b + (endColor.b - startColor.b) * ratio - ); - colors.push(`rgba(${r}, ${g}, ${b}, 1)`); - } - return colors; - }, - [styleConfig.gradientPaletteIndex] - ); - - // 根据分段数生成彩虹色 - const generateRainbowColors = useCallback( - (segments: number): string[] => { - const baseColors = - RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; - // 严格按顺序返回 N 个颜色 - return Array.from( - { length: segments }, - (_, i) => baseColors[i % baseColors.length] - ); - }, - [styleConfig.rainbowPaletteIndex] - ); - // 保存当前图层的样式状态 - const saveLayerStyle = ( - layerId?: string, - newLegendConfig?: LegendStyleConfig, - overrideStyleConfig?: StyleConfig - ) => { - const currentStyleConfig = overrideStyleConfig || styleConfig; - - if (!currentStyleConfig.property) { - console.warn("无法保存样式:缺少必要的图层或样式配置"); - return; - } - if (!layerId) return; - - const layerName = - newLegendConfig?.layerName || - selectedRenderLayer?.get("name") || - `图层${layerId}`; - const property = availableProperties.find( - (p) => p.value === currentStyleConfig.property - ); - const legendConfig: LegendStyleConfig = newLegendConfig || { - layerId, - layerName, - property: property?.name || currentStyleConfig.property, - colors: [], - type: selectedRenderLayer?.get("type") || "point", - dimensions: [], - breaks: [], - }; - - const newStyleState: LayerStyleState = { - layerId, - layerName, - styleConfig: { ...currentStyleConfig }, - legendConfig: { ...legendConfig }, - isActive: true, - }; - - setLayerStyleStates((prev) => { - const existingIndex = prev.findIndex((state) => state.layerId === layerId); - - if (existingIndex !== -1) { - const updated = [...prev]; - updated[existingIndex] = newStyleState; - return updated; - } - - return [...prev, newStyleState]; - }); - }; - // 设置分类样式参数,触发样式应用 - const setStyleState = () => { - if (!selectedRenderLayer) return; - const layerId = selectedRenderLayer.get("value"); - const property = styleConfig.property; - if (layerId !== undefined && property !== undefined) { - // 验证自定义断点设置 - if (styleConfig.classificationMethod === "custom_breaks") { - const expected = styleConfig.segments; - const custom = styleConfig.customBreaks || []; - if ( - custom.length !== expected || - custom.some((v) => v === undefined || v === null || isNaN(v)) - ) { - open?.({ - type: "error", - message: `请设置 ${expected} 个有效的自定义阈值(数字)`, - }); - return; - } - if (custom.some((v) => v < 0)) { - open?.({ type: "error", message: "自定义阈值必须大于等于 0" }); - return; - } - // 升序排序 - setStyleConfig((prev) => ({ - ...prev, - customBreaks: (prev.customBreaks || []) - .slice(0, expected) - .sort((a, b) => a - b), - })); - } - // 更新文字标签设置 - if (layerId === "junctions") { - setJunctionText && setJunctionText(property); - setShowJunctionTextLayer && - setShowJunctionTextLayer(styleConfig.showLabels); - setShowJunctionId && setShowJunctionId(styleConfig.showId); - setApplyJunctionStyle(true); - if (property === "pressure" && setContourLayerAvailable) { - setContourLayerAvailable(true); - } - saveLayerStyle(layerId); - open?.({ - type: "success", - message: "节点图层样式设置成功,等待数据更新。", - }); - } - if (layerId === "pipes") { - setPipeText && setPipeText(property); - setShowPipeTextLayer && setShowPipeTextLayer(styleConfig.showLabels); - setShowPipeId && setShowPipeId(styleConfig.showId); - setApplyPipeStyle(true); - setWaterflowLayerAvailable && setWaterflowLayerAvailable(true); - saveLayerStyle(layerId); - open?.({ - type: "success", - message: "管道图层样式设置成功,等待数据更新。", - }); - } - // 触发样式更新 - setStyleUpdateTrigger((prev) => prev + 1); - } - }; - // 计算分类样式,并应用到对应图层 - const applyClassificationStyle = ( - layerType: "junctions" | "pipes", - styleConfig: any - ) => { - const isElevation = - layerType === "junctions" && styleConfig.property === "elevation"; - const isDiameter = - layerType === "pipes" && styleConfig.property === "diameter"; - const isUnitHeadloss = - layerType === "pipes" && styleConfig.property === "unit_headloss"; - - if ( - layerType === "junctions" && - ((currentJunctionCalData && currentJunctionCalData.length > 0) || - (isElevation && elevationRange)) - ) { - // 应用节点样式 - let junctionStyleConfigState = layerStyleStates.find( - (s) => s.layerId === "junctions" - ); - - // 更新节点数据属性 - const segments = junctionStyleConfigState?.styleConfig.segments ?? 5; - let breaks: number[] = []; - - const dataValues = - isElevation && elevationRange - ? [elevationRange[0], elevationRange[1]] - : currentJunctionCalData?.map((d: any) => d.value) || []; - - if (dataValues.length === 0) return; - - if ( - junctionStyleConfigState?.styleConfig.classificationMethod === - "custom_breaks" - ) { - // 使用自定义断点(保证为 segments 个断点,按升序) - const desired = segments; - breaks = ( - junctionStyleConfigState?.styleConfig.customBreaks || [] - ).slice(0, desired); - breaks.sort((a, b) => a - b); - // 过滤出 >= 0 - breaks = breaks.filter((v) => v >= 0); - // 如果不足则补齐最后一个值 - while (breaks.length < desired) - breaks.push(breaks[breaks.length - 1] ?? 0); - } else { - const calc = calculateClassification( - dataValues, - segments, - styleConfig.classificationMethod - ); - breaks = calc; - } - if (breaks.length === 0) { - console.warn("计算的 breaks 为空,无法应用样式"); - return; - } - // 计算最大最小值,判断是否包含并插入 breaks - const min_val = Math.max( - dataValues.reduce((min, val) => Math.min(min, val), Infinity), - 0 - ); - const max_val = dataValues.reduce( - (max, val) => Math.max(max, val), - -Infinity - ); - if (breaks.includes(min_val) === false) { - breaks.push(min_val); - breaks.sort((a, b) => a - b); - } - if (breaks.includes(max_val) === false) { - breaks.push(max_val); - breaks.sort((a, b) => a - b); - } - if (junctionStyleConfigState) { - applyLayerStyle(junctionStyleConfigState, breaks); - applyContourLayerStyle(junctionStyleConfigState, breaks); - } - } else if ( - layerType === "pipes" && - ((currentPipeCalData && currentPipeCalData.length > 0) || - (isDiameter && diameterRange) || - isUnitHeadloss) - ) { - // 应用管道样式 - let pipeStyleConfigState = layerStyleStates.find( - (s) => s.layerId === "pipes" - ); - // 更新管道数据属性 - const segments = pipeStyleConfigState?.styleConfig.segments ?? 5; - let breaks: number[] = []; - - const dataValues = - isDiameter && diameterRange - ? [diameterRange[0], diameterRange[1]] - : isUnitHeadloss - ? [unitHeadlossRange[0], unitHeadlossRange[1]] - : currentPipeCalData?.map((d: any) => d.value) || []; - - if (dataValues.length === 0) return; - - if ( - pipeStyleConfigState?.styleConfig.classificationMethod === - "custom_breaks" - ) { - // 使用自定义断点(保证为 segments 个断点,按升序) - const desired = segments; - breaks = (pipeStyleConfigState?.styleConfig.customBreaks || []).slice( - 0, - desired - ); - breaks.sort((a, b) => a - b); - breaks = breaks.filter((v) => v >= 0); - while (breaks.length < desired) - breaks.push(breaks[breaks.length - 1] ?? 0); - } else { - const calc = calculateClassification( - dataValues, - segments, - styleConfig.classificationMethod - ); - breaks = calc; - } - if (breaks.length === 0) { - console.warn("计算的 breaks 为空,无法应用样式"); - return; - } - // 计算最大最小值,判断是否包含并插入 breaks - const min_val = Math.max( - dataValues.reduce((min, val) => Math.min(min, val), Infinity), - 0 - ); - const max_val = dataValues.reduce( - (max, val) => Math.max(max, val), - -Infinity - ); - if (breaks.includes(min_val) === false) { - breaks.push(min_val); - breaks.sort((a, b) => a - b); - } - if (breaks.includes(max_val) === false) { - breaks.push(max_val); - breaks.sort((a, b) => a - b); - } - if (pipeStyleConfigState) applyLayerStyle(pipeStyleConfigState, breaks); - } - }; - // 应用样式函数,传入 breaks 数据 - const applyLayerStyle = ( - layerStyleConfig: LayerStyleState, - breaks?: number[] - ) => { - // 使用传入的 breaks 数据 - if (!breaks || breaks.length === 0) { - console.warn("没有有效的 breaks 数据"); - return; - } - const styleConfig = layerStyleConfig.styleConfig; - const targetLayers = getRenderLayersById(layerStyleConfig.layerId); - const renderLayer = targetLayers[0]; - if (!renderLayer || !styleConfig?.property) return; - const layerType: string = renderLayer.get("type"); - - const breaksLength = breaks.length; - // 根据 breaks 计算每个分段的颜色,线条粗细 - const colors: string[] = - styleConfig.colorType === "single" - ? // 单一色重复多次 - Array.from({ length: breaksLength }, () => { - return SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color; - }) - : styleConfig.colorType === "gradient" - ? generateGradientColors(breaksLength) - : styleConfig.colorType === "rainbow" - ? generateRainbowColors(breaksLength) - : (() => { - // 自定义颜色 - const custom = styleConfig.customColors || []; - // 如果自定义颜色数量不足,用反向彩虹色填充 - const result = [...custom]; - const reverseRainbowColors = RAINBOW_PALETTES[1].colors; - while (result.length < breaksLength) { - result.push( - reverseRainbowColors[ - (result.length - custom.length) % reverseRainbowColors.length - ] - ); - } - return result.slice(0, breaksLength); - })(); - // 计算每个分段的线条粗细和点大小 - const dimensions: number[] = - layerType === "linestring" - ? styleConfig.adjustWidthByProperty - ? Array.from({ length: breaksLength }, (_, i) => { - const ratio = i / (breaksLength - 1); - return ( - styleConfig.minStrokeWidth + - (styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * - ratio - ); - }) - : Array.from( - { length: breaksLength }, - () => styleConfig.fixedStrokeWidth - ) // 使用固定宽度 - : Array.from({ length: breaksLength }, (_, i) => { - const ratio = i / (breaksLength - 1); - return ( - styleConfig.minSize + - (styleConfig.maxSize - styleConfig.minSize) * ratio - ); - }); - - // 动态生成颜色条件表达式 - const generateColorConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let i = 1; i < breaks.length; i++) { - // 添加条件:属性值 <= 当前断点 - if (property === "unit_headloss") { - conditions.push([ - "<=", - ["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]], - breaks[i], - ]); - } else { - conditions.push(["<=", ["get", property], breaks[i]]); - } - // 添加对应的颜色值 - const colorObj = parseColor(colors[i - 1]); - const color = `rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`; - conditions.push(color); - } - const colorObj = parseColor(colors[0]); - const color = `rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`; - // 添加默认值(首个颜色) - conditions.push(color); - return conditions; - }; - // 动态生成尺寸条件表达式 - const generateDimensionConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let i = 0; i < breaks.length; i++) { - // 单独处理 unit_headloss 属性 - if (property === "unit_headloss") { - conditions.push([ - "<=", - ["/", ["get", "headloss"], ["get", "length"]], - breaks[i], - ]); - } else { - conditions.push(["<=", ["get", property], breaks[i]]); - } - conditions.push(dimensions[i]); - } - conditions.push(dimensions[dimensions.length - 1]); - return conditions; - }; - const generateDimensionPointConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let i = 0; i < breaks.length; i++) { - conditions.push(["<=", ["get", property], breaks[i]]); - conditions.push([ - "interpolate", - ["linear"], - ["zoom"], - 12, - 1, // 使用配置的最小尺寸 - 24, - dimensions[i], - ]); - } - conditions.push(dimensions[dimensions.length - 1]); - return conditions; - }; - // 创建基于 breaks 的动态 FlatStyle - const dynamicStyle: FlatStyleLike = {}; - - // 根据图层类型设置不同的样式属性 - if (layerType === "linestring") { - dynamicStyle["stroke-color"] = generateColorConditions( - styleConfig.property - ); - dynamicStyle["stroke-width"] = generateDimensionConditions( - styleConfig.property - ); - } else if (layerType === "point") { - dynamicStyle["circle-fill-color"] = generateColorConditions( - styleConfig.property - ); - dynamicStyle["circle-radius"] = generateDimensionPointConditions( - styleConfig.property - ); - dynamicStyle["circle-stroke-color"] = generateColorConditions( - styleConfig.property - ); - dynamicStyle["circle-stroke-width"] = 2; - } - // 应用样式到图层 - targetLayers.forEach((targetLayer) => { - targetLayer.setStyle(dynamicStyle); - }); - // 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性 - const layerId = renderLayer.get("value"); - const initLayerStyleState = layerStyleStates.find( - (s) => s.layerId === layerId - ); - // 创建图例配置对象 - const legendConfig: LegendStyleConfig = { - layerName: initLayerStyleState?.layerName || `图层${layerId}`, - layerId: layerId, - property: initLayerStyleState?.legendConfig.property || "", - colors: colors, - type: layerType, - dimensions: dimensions, - breaks: breaks, - }; - // 自动保存样式状态,直接传入图例配置 - setTimeout(() => { - saveLayerStyle(renderLayer.get("value"), legendConfig, styleConfig); - }, 100); - }; - // 应用样式函数,传入 breaks 数据 - const applyContourLayerStyle = ( - layerStyleConfig: LayerStyleState, - breaks?: number[] - ) => { - // 使用传入的 breaks 数据 - if (!breaks || breaks.length === 0) { - console.warn("没有有效的 breaks 数据"); - return; - } - const styleConfig = layerStyleConfig.styleConfig; - - if (!setContours) return; - - const breaksLength = breaks.length; - // 根据 breaks 计算每个分段的颜色 - const colors: string[] = - styleConfig.colorType === "single" - ? // 单一色重复多次 - Array.from({ length: breaksLength }, () => { - return SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color; - }) - : styleConfig.colorType === "gradient" - ? generateGradientColors(breaksLength) - : styleConfig.colorType === "rainbow" - ? generateRainbowColors(breaksLength) - : (() => { - // 自定义颜色 - const custom = styleConfig.customColors || []; - // 如果自定义颜色数量不足,用反向彩虹色填充 - const result = [...custom]; - const reverseRainbowColors = RAINBOW_PALETTES[1].colors; - while (result.length < breaksLength) { - result.push( - reverseRainbowColors[ - (result.length - custom.length) % reverseRainbowColors.length - ] - ); - } - return result.slice(0, breaksLength); - })(); - - // 构造 ContourLayer 所需的 contours 配置 - const contours = []; - for (let i = 0; i < breaks.length - 1; i++) { - const colorObj = parseColor(colors[i]); - contours.push({ - threshold: [breaks[i], breaks[i + 1]], - color: [ - colorObj.r, - colorObj.g, - colorObj.b, - Math.round(styleConfig.opacity * 255), - ], - strokeWidth: 0, - }); - } - // 应用样式到等值线图层 - setContours(contours); - }; - - // 重置样式 - const resetStyle = () => { - if (!selectedRenderLayer) return; - // 重置 WebGL 图层样式 - const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; - const layerId = selectedRenderLayer.get("value"); - getRenderLayersById(layerId).forEach((targetLayer) => { - targetLayer.setStyle(defaultFlatStyle); - }); - - // 删除对应图层的样式状态,从而移除图例显示 - if (layerId !== undefined) { - setLayerStyleStates((prev) => - prev.filter((state) => state.layerId !== layerId) - ); - // 重置样式应用状态 - if (layerId === "junctions") { - setApplyJunctionStyle(false); - if (setShowJunctionTextLayer) setShowJunctionTextLayer(false); - if (setShowJunctionId) setShowJunctionId(false); - if (setJunctionText) setJunctionText(""); - setContours && setContours([]); - setContourLayerAvailable && setContourLayerAvailable(false); - } else if (layerId === "pipes") { - setApplyPipeStyle(false); - if (setShowPipeTextLayer) setShowPipeTextLayer(false); - if (setShowPipeId) setShowPipeId(false); - if (setPipeText) setPipeText(""); - setWaterflowLayerAvailable && setWaterflowLayerAvailable(false); - } - } - }; - // 更新当前 VectorTileSource 中的所有缓冲要素属性 - const updateVectorTileSource = ( - targetMap: OlMap, - layerId: string, - property: string, - data: any[] - ) => { - const vectorTileSources = targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source); - - if (!vectorTileSources.length) return; - - // 创建 id 到 value 的映射 - const dataMap = new Map<string, number>(); - data.forEach((d: any) => { - dataMap.set(d.ID, d.value || 0); - }); - - // 直接遍历所有瓦片和要素,无需分批处理 - vectorTileSources.forEach((vectorTileSource) => { - const sourceTiles = vectorTileSource.sourceTiles_; - - Object.values(sourceTiles).forEach((vectorTile) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - // 直接更新要素属性 - renderFeatures.forEach((renderFeature) => { - const featureId = renderFeature.get("id"); - const value = dataMap.get(featureId); - if (value !== undefined) { - if (property === "flow") { - // 特殊处理流量属性,取绝对值 - (renderFeature as any).properties_[property] = Math.abs(value); - } else { - (renderFeature as any).properties_[property] = value; - } - } - }); - }); - }); - }; - // 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性 - const tileLoadListenersRef = useRef< - Map<string, { source: VectorTileSource; listener: (event: any) => void }> - >(new Map()); - - const attachVectorTileSourceLoadedEvent = ( - targetMap: OlMap, - layerId: string, - property: string, - data: any[] - ) => { - const vectorTileSource = targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source)[0]; - if (!vectorTileSource) return; - // 创建 id 到 value 的映射 - const dataMap = new Map<string, number>(); - data.forEach((d: any) => { - dataMap.set(d.ID, d.value || 0); - }); - // 新增监听器并保存 - const listener = (event: any) => { - try { - if (event.tile instanceof VectorTile) { - const renderFeatures = event.tile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - // 直接更新要素属性 - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = dataMap.get(featureId); - if (value !== undefined) { - if (property === "flow") { - // 特殊处理流量属性,取绝对值 - (renderFeature as any).properties_[property] = Math.abs(value); - } else { - (renderFeature as any).properties_[property] = value; - } - } - }); - } - } catch (error) { - console.error("Error processing tile load event:", error); - } - }; - - const listenerKey = getMapKey(targetMap, layerId); - vectorTileSource.on("tileloadend", listener); - tileLoadListenersRef.current.set(listenerKey, { - source: vectorTileSource, - listener, - }); - }; - // 新增函数:取消对应 layerId 已添加的 on 事件 - const removeVectorTileSourceLoadedEvent = useCallback( - (targetMap: OlMap, layerId: string) => { - const listenerKey = getMapKey(targetMap, layerId); - const listenerState = tileLoadListenersRef.current.get(listenerKey); - if (listenerState) { - listenerState.source.un("tileloadend", listenerState.listener); - tileLoadListenersRef.current.delete(listenerKey); - } - }, - [getMapKey] - ); - - // 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发 - useEffect(() => { - // 判断此次触发是否由用户点击“应用”按钮引起 - const isUserTrigger = - styleUpdateTrigger !== prevStyleUpdateTriggerRef.current; - // 更新 prevStyleUpdateTrigger - prevStyleUpdateTriggerRef.current = styleUpdateTrigger; - - const updateJunctionStyle = () => { - const junctionStyleConfigState = layerStyleStates.find( - (s) => s.layerId === "junctions" - ); - const isElevation = - junctionStyleConfigState?.styleConfig.property === "elevation"; - - // setStyle() 会清除渲染器缓存,这是闪烁的主要原因 WebGLVectorTile.js:114-118 - // 尝试考虑使用 updateStyleVariables() 更新 - applyClassificationStyle( - "junctions", - junctionStyleConfigState?.styleConfig - ); - - if (isElevation) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - }); - return; - } - - activeMaps.forEach((targetMap) => { - const targetData = getDataForMap(targetMap, "junctions"); - if (!targetData || targetData.length === 0) return; - updateVectorTileSource(targetMap, "junctions", junctionText, targetData); - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - attachVectorTileSourceLoadedEvent( - targetMap, - "junctions", - junctionText, - targetData - ); - }); - }; - const updatePipeStyle = () => { - const pipeStyleConfigState = layerStyleStates.find( - (s) => s.layerId === "pipes" - ); - const isDiameter = - pipeStyleConfigState?.styleConfig.property === "diameter"; - - applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig); - - if (isDiameter) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - return; - } - - activeMaps.forEach((targetMap) => { - const targetData = getDataForMap(targetMap, "pipes"); - if (!targetData || targetData.length === 0) return; - updateVectorTileSource(targetMap, "pipes", pipeText, targetData); - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - attachVectorTileSourceLoadedEvent( - targetMap, - "pipes", - pipeText, - targetData - ); - }); - }; - if (isUserTrigger) { - if (selectedRenderLayer?.get("value") === "junctions") { - updateJunctionStyle(); - } else if (selectedRenderLayer?.get("value") === "pipes") { - updatePipeStyle(); - } - return; - } - - const isElevation = junctionText === "elevation"; - const isDiameter = pipeText === "diameter"; - - if ( - applyJunctionStyle && - ((currentJunctionCalData && currentJunctionCalData.length > 0) || - isElevation) - ) { - updateJunctionStyle(); - } - if ( - applyPipeStyle && - ((currentPipeCalData && currentPipeCalData.length > 0) || isDiameter) - ) { - updatePipeStyle(); - } - if (!applyJunctionStyle) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - }); - } - if (!applyPipeStyle) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - } - // This effect is intentionally driven by explicit style triggers and data snapshots. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - styleUpdateTrigger, - applyJunctionStyle, - applyPipeStyle, - currentJunctionCalData, - currentPipeCalData, - compareJunctionCalData, - comparePipeCalData, - activeMaps, - ]); - - useEffect(() => { - return () => { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - }; - }, [activeMaps, removeVectorTileSourceLoadedEvent]); - - // 获取地图中的矢量图层,用于选择图层选项 - useEffect(() => { - if (!map) return; - - const updateVisibleLayers = () => { - const layers = map.getAllLayers(); - // 筛选矢量瓦片图层 - const webGLVectorTileLayers = layers.filter( - (layer) => - layer.get("value") === "junctions" || layer.get("value") === "pipes" // 暂时只处理这两个图层 - ) as WebGLVectorTileLayer[]; - - setRenderLayers(webGLVectorTileLayers); - }; - - updateVisibleLayers(); - }, [map]); - if (!data) { + if (!isReady) { return <div>Loading...</div>; } - const getColorSetting = () => { - if (styleConfig.colorType === "single") { - return ( - <FormControl - variant="standard" - fullWidth - margin="dense" - className="mt-3" - > - <InputLabel>单一色方案</InputLabel> - <Select - value={styleConfig.singlePaletteIndex} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - singlePaletteIndex: Number(e.target.value), - })) - } - > - {SINGLE_COLOR_PALETTES.map((p, idx) => { - return ( - <MenuItem key={idx} value={idx}> - <Box - width="100%" - sx={{ display: "flex", alignItems: "center" }} - > - <Box - key={idx} - sx={{ - width: "80%", - height: 16, - borderRadius: 2, - background: p.color, - marginRight: 1, - border: "1px solid #ccc", - }} - /> - </Box> - </MenuItem> - ); - })} - </Select> - </FormControl> - ); - } - if (styleConfig.colorType === "gradient") { - return ( - <FormControl - variant="standard" - fullWidth - margin="dense" - className="mt-3" - > - <InputLabel>渐进色方案</InputLabel> - <Select - value={styleConfig.gradientPaletteIndex} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - gradientPaletteIndex: Number(e.target.value), - })) - } - > - {GRADIENT_PALETTES.map((p, idx) => { - const numColors = styleConfig.segments + 1; - const previewColors = Array.from( - { length: numColors }, - (_, i) => { - const ratio = numColors > 1 ? i / (numColors - 1) : 1; - const startColor = parseColor(p.start); - const endColor = parseColor(p.end); - const r = Math.round( - startColor.r + (endColor.r - startColor.r) * ratio - ); - const g = Math.round( - startColor.g + (endColor.g - startColor.g) * ratio - ); - const b = Math.round( - startColor.b + (endColor.b - startColor.b) * ratio - ); - return `rgba(${r}, ${g}, ${b}, 1)`; - } - ); - return ( - <MenuItem key={idx} value={idx}> - <Box - width="100%" - sx={{ display: "flex", alignItems: "center" }} - > - <Box - sx={{ - width: "80%", - height: 16, - borderRadius: 2, - display: "flex", - overflow: "hidden", - marginRight: 1, - border: "1px solid #ccc", - }} - > - {previewColors.map((color, colorIdx) => ( - <Box - key={colorIdx} - sx={{ - flex: 1, - backgroundColor: color, - }} - /> - ))} - </Box> - </Box> - </MenuItem> - ); - })} - </Select> - </FormControl> - ); - } - if (styleConfig.colorType === "rainbow") { - return ( - <FormControl - variant="standard" - fullWidth - margin="dense" - className="mt-3" - > - <InputLabel>离散彩虹方案</InputLabel> - <Select - value={styleConfig.rainbowPaletteIndex} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - rainbowPaletteIndex: Number(e.target.value), - })) - } - > - {RAINBOW_PALETTES.map((p, idx) => { - // 根据当前分段数+1生成该方案的预览颜色 - const baseColors = p.colors; - const numColors = styleConfig.segments + 1; - const previewColors = Array.from( - { length: numColors }, - (_, i) => baseColors[i % baseColors.length] - ); - - return ( - <MenuItem key={idx} value={idx}> - <Box - width="100%" - sx={{ display: "flex", alignItems: "center" }} - > - <Typography sx={{ marginRight: 1 }}>{p.name}</Typography> - <Box - sx={{ - width: "60%", - height: 16, - borderRadius: 2, - display: "flex", - border: "1px solid #ccc", - overflow: "hidden", - }} - > - {previewColors.map((color, colorIdx) => ( - <Box - key={colorIdx} - sx={{ - flex: 1, - backgroundColor: color, - }} - /> - ))} - </Box> - </Box> - </MenuItem> - ); - })} - </Select> - </FormControl> - ); - } - if (styleConfig.colorType === "custom") { - return ( - <Box className="mt-3"> - <Typography variant="subtitle2" gutterBottom> - 自定义颜色 - </Typography> - <Box - className="flex flex-col gap-2" - sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }} - > - {Array.from({ length: styleConfig.segments }).map((_, idx) => { - const color = - (styleConfig.customColors && styleConfig.customColors[idx]) || - "rgba(0,0,0,1)"; - return ( - <Box key={idx} className="flex items-center gap-2"> - <Typography variant="caption" sx={{ width: 40 }}> - 分段{idx + 1} - </Typography> - <input - type="color" - value={rgbaToHex(color)} - onChange={(e) => { - const hex = e.target.value; - const newColor = hexToRgba(hex); - setStyleConfig((prev) => { - const newColors = [...(prev.customColors || [])]; - while (newColors.length < styleConfig.segments) - newColors.push("rgba(0,0,0,1)"); - newColors[idx] = newColor; - return { ...prev, customColors: newColors }; - }); - }} - style={{ - width: "100%", - height: "32px", - cursor: "pointer", - border: "1px solid #ccc", - borderRadius: "4px", - }} - /> - </Box> - ); - })} - </Box> - </Box> - ); - } - }; - // 根据不同图层的类型和颜色分类方案显示不同的大小设置 - const getSizeSetting = () => { - let colors: string[] = []; - if (styleConfig.colorType === "single") { - const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color; - colors = [color, color]; - } else if (styleConfig.colorType === "gradient") { - const { start, end } = - GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; - colors = [start, end]; - } else if (styleConfig.colorType === "rainbow") { - const rainbowColors = - RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; - colors = [rainbowColors[0], rainbowColors[rainbowColors.length - 1]]; - } else if (styleConfig.colorType === "custom") { - const customColors = styleConfig.customColors || []; - colors = [ - customColors[0] || "rgba(0,0,0,1)", - customColors[customColors.length - 1] || "rgba(0,0,0,1)", - ]; - } - - if (selectedRenderLayer?.get("type") === "point") { - return ( - <Box className="mt-3"> - <Typography gutterBottom> - 点大小范围: {styleConfig.minSize} - {styleConfig.maxSize} 像素 - </Typography> - <Box className="flex items-center gap-4"> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最小值 - </Typography> - <Slider - value={styleConfig.minSize} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - minSize: value as number, - })) - } - min={2} - max={8} - step={1} - size="small" - /> - </Box> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最大值 - </Typography> - <Slider - value={styleConfig.maxSize} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - maxSize: value as number, - })) - } - min={10} - max={16} - step={1} - size="small" - /> - </Box> - </Box> - {/* 点大小预览 */} - <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> - <Typography variant="caption">预览:</Typography> - <Box - sx={{ - width: styleConfig.minSize, - height: styleConfig.minSize, - borderRadius: "50%", - backgroundColor: colors[0], - }} - /> - <Typography variant="caption">到</Typography> - <Box - sx={{ - width: styleConfig.maxSize, - height: styleConfig.maxSize, - borderRadius: "50%", - backgroundColor: colors[colors.length - 1], - }} - /> - </Box> - </Box> - ); - } - if (selectedRenderLayer?.get("type") === "linestring") { - return ( - <Box className="mt-3"> - {/* 勾选项:是否根据属性调整线条宽度 */} - <FormControlLabel - control={ - <Checkbox - checked={styleConfig.adjustWidthByProperty} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - adjustWidthByProperty: e.target.checked, - })) - } - disabled={styleConfig.colorType === "single"} - /> - } - label="根据数值分段调整线条宽度" - /> - {styleConfig.adjustWidthByProperty && ( - <> - <Typography gutterBottom> - 线条宽度范围: {styleConfig.minStrokeWidth} -{" "} - {styleConfig.maxStrokeWidth}px - </Typography> - <Box className="flex items-center gap-4"> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最小值 - </Typography> - <Slider - value={styleConfig.minStrokeWidth} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - minStrokeWidth: value as number, - })) - } - min={1} - max={4} - step={0.5} - size="small" - /> - </Box> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最大值 - </Typography> - <Slider - value={styleConfig.maxStrokeWidth} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - maxStrokeWidth: value as number, - })) - } - min={6} - max={12} - step={0.5} - size="small" - /> - </Box> - </Box> - {/* 线条宽度预览 */} - <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> - <Typography variant="caption">预览:</Typography> - <Box - sx={{ - width: 50, - height: styleConfig.minStrokeWidth, - backgroundColor: colors[0], - border: `1px solid ${colors[0]}`, - borderRadius: 1, - }} - /> - <Typography variant="caption">到</Typography> - <Box - sx={{ - width: 50, - height: styleConfig.maxStrokeWidth, - backgroundColor: colors[colors.length - 1], - border: `1px solid ${colors[colors.length - 1]}`, - borderRadius: 1, - }} - /> - </Box> - </> - )} - {!styleConfig.adjustWidthByProperty && ( - <> - <Typography gutterBottom> - 固定线条宽度: {styleConfig.fixedStrokeWidth}px - </Typography> - <Slider - value={styleConfig.fixedStrokeWidth} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - fixedStrokeWidth: value as number, - })) - } - min={1} - max={10} - step={0.5} - size="small" - /> - {/* 固定宽度预览 */} - <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> - <Typography variant="caption">预览:</Typography> - <Box - sx={{ - width: 50, - height: styleConfig.fixedStrokeWidth, - backgroundColor: colors[0], - border: `1px solid ${colors[0]}`, - borderRadius: 1, - }} - /> - </Box> - </> - )} - </Box> - ); - } - }; - return ( - <> - <div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300"> - {/* 图层选择 */} - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel>选择图层</InputLabel> - <Select - value={ - selectedRenderLayer - ? renderLayers.indexOf(selectedRenderLayer) - : "" - } - onChange={(e) => { - const index = e.target.value as number; - const newLayer = index >= 0 ? renderLayers[index] : undefined; - setSelectedRenderLayer(newLayer); - - // 检查新图层是否有缓存的样式,没有才清空 - if (newLayer) { - const layerId = newLayer.get("value"); - const cachedStyleState = layerStyleStates.find( - (state) => state.layerId === layerId - ); - if (cachedStyleState) { - setStyleConfig(cachedStyleState.styleConfig); - } else { - setStyleConfig((prev) => ({ - ...prev, - property: "", - customBreaks: - prev.classificationMethod === "custom_breaks" - ? getDefaultCustomBreaks(prev.segments, "", newLayer) - : prev.customBreaks, - customColors: getDefaultCustomColors( - prev.segments, - prev.customColors - ), - })); - } - } - }} - > - {renderLayers.map((layer, index) => { - const name = layer.get("name"); - return ( - <MenuItem key={index} value={index}> - {name} - </MenuItem> - ); - })} - </Select> - </FormControl> - {/* 属性选择 */} - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel>分级属性</InputLabel> - <Select - value={styleConfig.property} - onChange={(e) => { - const nextProperty = e.target.value; - setStyleConfig((prev) => ({ - ...prev, - property: nextProperty, - customBreaks: - prev.classificationMethod === "custom_breaks" - ? getDefaultCustomBreaks(prev.segments, nextProperty) - : prev.customBreaks, - })); - }} - disabled={!selectedRenderLayer} - > - {availableProperties.map((prop) => ( - <MenuItem key={prop.name} value={prop.value}> - {prop.name} - </MenuItem> - ))} - </Select> - </FormControl> - {/* 分类选择 */} - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel>分类方法</InputLabel> - <Select - value={styleConfig.classificationMethod} - onChange={(e) => { - const nextMethod = e.target.value; - setStyleConfig((prev) => ({ - ...prev, - classificationMethod: nextMethod, - customBreaks: - nextMethod === "custom_breaks" - ? getDefaultCustomBreaks(prev.segments, prev.property) - : prev.customBreaks, - })); - }} - > - {CLASSIFICATION_METHODS.map((method) => ( - <MenuItem key={method.value} value={method.value}> - {method.name} - </MenuItem> - ))} - </Select> - </FormControl> - {/* 分类数量 */} - <Box className="mt-3"> - <Typography gutterBottom>分类数量: {styleConfig.segments}</Typography> - <Slider - value={styleConfig.segments} - onChange={(_, value) => - setStyleConfig((prev) => { - const newSegments = value as number; - const newCustomColors = [...(prev.customColors || [])]; - if (newSegments > newCustomColors.length) { - const baseColors = RAINBOW_PALETTES[0].colors; - for (let i = newCustomColors.length; i < newSegments; i++) { - newCustomColors.push(baseColors[i % baseColors.length]); - } - } - return { - ...prev, - segments: newSegments, - customBreaks: - prev.classificationMethod === "custom_breaks" - ? getDefaultCustomBreaks(newSegments, prev.property) - : prev.customBreaks, - customColors: getDefaultCustomColors( - newSegments, - newCustomColors - ), - }; - }) - } - min={2} - max={10} - step={1} - marks - size="small" - /> - </Box> - {/* 自定义分类:手动填写区间分段(仅当选择自定义方式时显示) */} - {styleConfig.classificationMethod === "custom_breaks" && ( - <Box className="mt-3 p-2 bg-gray-50 rounded"> - <Typography variant="subtitle2" gutterBottom> - 手动设置区间阈值(按升序填写,最小值 {">="} 0) - </Typography> - <Box - className="flex flex-col gap-2" - sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }} - > - {Array.from({ length: styleConfig.segments }).map((_, idx) => ( - <TextField - key={idx} - label={`阈值 ${idx + 1}`} - type="number" - size="small" - slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }} - value={ - (styleConfig.customBreaks && - styleConfig.customBreaks[idx]) ?? - "" - } - onChange={(e) => { - const v = parseFloat(e.target.value); - setStyleConfig((prev) => { - const prevBreaks = prev.customBreaks - ? [...prev.customBreaks] - : []; - // 保证长度 - while (prevBreaks.length < styleConfig.segments) - prevBreaks.push(0); - prevBreaks[idx] = isNaN(v) ? 0 : Math.max(0, v); - return { ...prev, customBreaks: prevBreaks }; - }); - }} - onBlur={() => { - // on blur 保证升序 - setStyleConfig((prev) => { - const prevBreaks = (prev.customBreaks || []).slice( - 0, - styleConfig.segments + 1 - ); - prevBreaks.sort((a, b) => a - b); - return { ...prev, customBreaks: prevBreaks }; - }); - }} - /> - ))} - </Box> - </Box> - )} - {/* 颜色方案 */} - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel> - <ColorLensIcon className="mr-1" /> - 颜色方案 - </InputLabel> - <Select - value={styleConfig.colorType} - onChange={(e) => { - const newColorType = e.target.value; - setStyleConfig((prev) => { - let newCustomColors = prev.customColors; - if ( - newColorType === "custom" && - (!prev.customColors || prev.customColors.length === 0) - ) { - const baseColors = RAINBOW_PALETTES[0].colors; - newCustomColors = Array.from( - { length: prev.segments }, - (_, i) => baseColors[i % baseColors.length] - ); - } - return { - ...prev, - colorType: newColorType, - adjustWidthByProperty: - newColorType === "single" - ? true - : prev.adjustWidthByProperty, - customColors: newCustomColors, - }; - }); - }} - > - <MenuItem value="single">单一色</MenuItem> - <MenuItem value="gradient">渐进色</MenuItem> - <MenuItem value="rainbow">离散彩虹</MenuItem> - <MenuItem value="custom">自定义</MenuItem> - </Select> - {getColorSetting()} - </FormControl> - - {/* 大小设置 */} - {getSizeSetting()} - - {/* 透明度设置 */} - <Box className="mt-3"> - <Typography gutterBottom> - 透明度: {(styleConfig.opacity * 100).toFixed(0)}% - </Typography> - <Slider - value={styleConfig.opacity} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - opacity: value as number, - })) - } - min={0.1} - max={1} - step={0.05} - size="small" - /> - </Box> - - {/* 是否显示ID */} - <FormControlLabel - control={ - <Checkbox - checked={styleConfig.showId} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - showId: e.target.checked, - })) - } - /> - } - label="显示 ID(缩放 >=15 级时显示)" - /> - - {/* 是否显示属性文字 */} - <FormControlLabel - control={ - <Checkbox - checked={styleConfig.showLabels} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - showLabels: e.target.checked, - })) - } - /> - } - label="显示属性(缩放 >=15 级时显示)" - /> - <div className="my-3"></div> - {/* 操作按钮 */} - <Box className="flex gap-2"> - <Button - variant="contained" - color="primary" - onClick={() => { - setStyleState(); - }} - disabled={!selectedRenderLayer || !styleConfig.property} - startIcon={<ApplyIcon />} - fullWidth - > - 应用 - </Button> - <Button - variant="outlined" - onClick={() => { - resetStyle(); - }} - disabled={!selectedRenderLayer} - startIcon={<ResetIcon />} - fullWidth - > - 重置 - </Button> - </Box> - </div> - </> + <StyleEditorForm + renderLayers={renderLayers} + selectedRenderLayer={selectedRenderLayer} + styleConfig={styleConfig} + setStyleConfig={setStyleConfig} + availableProperties={availableProperties} + onLayerChange={handleLayerChange} + onPropertyChange={handlePropertyChange} + onClassificationMethodChange={handleClassificationMethodChange} + onSegmentsChange={handleSegmentsChange} + onCustomBreakChange={handleCustomBreakChange} + onCustomBreakBlur={handleCustomBreakBlur} + onColorTypeChange={handleColorTypeChange} + onApply={handleApply} + onReset={handleReset} + /> ); }; export default StyleEditorPanel; +export type { LayerStyleState, StyleConfig } from "./styleEditorTypes"; +export { createDefaultLayerStyleState, createDefaultLayerStyleStates }; diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index 6bfbfce..41b1c3f 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -85,6 +85,7 @@ const Timeline: React.FC<TimelineProps> = ({ const isCompareMode = data?.isCompareMode ?? false; const junctionText = data?.junctionText ?? ""; const pipeText = data?.pipeText ?? ""; + const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion; const { open } = useNotification(); const [isPlaying, setIsPlaying] = useState<boolean>(false); const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒 @@ -657,6 +658,7 @@ const Timeline: React.FC<TimelineProps> = ({ }); // 清空当天当前时刻及之后的缓存并重新获取数据 clearCacheAndRefetch(calculationDate, calculationTime); + setForceStyleAutoApplyVersion?.((prev) => prev + 1); } else { open?.({ type: "error", diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 03f38f7..333434e 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -14,7 +14,8 @@ import VectorLayer from "ol/layer/Vector"; import { Style, Stroke, Fill, Circle } from "ol/style"; import Feature from "ol/Feature"; import StyleEditorPanel from "./StyleEditorPanel"; -import { LayerStyleState } from "./StyleEditorPanel"; +import { createDefaultLayerStyleStates } from "./styleEditorPresets"; +import { LayerStyleState } from "./styleEditorTypes"; import StyleLegend from "./StyleLegend"; // 引入图例组件 import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import { useNotification } from "@refinedev/core"; @@ -90,81 +91,9 @@ const Toolbar: React.FC<ToolbarProps> = ({ }); // 样式状态管理 - 在 Toolbar 中管理,带有默认样式 - const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([ - { - isActive: false, // 默认不激活,不显示图例 - layerId: "junctions", - layerName: "节点", - styleConfig: { - property: "pressure", - classificationMethod: "custom_breaks", - customBreaks: [16, 18, 20, 22, 24, 26], - customColors: [ - "rgba(255, 0, 0, 1)", - "rgba(255, 127, 0, 1)", - "rgba(255, 215, 0, 1)", - "rgba(199, 224, 0, 1)", - "rgba(76, 175, 80, 1)", - "rgba(0, 158, 115, 1)", - ], - segments: 6, - minSize: 4, - maxSize: 12, - minStrokeWidth: 2, - maxStrokeWidth: 8, - fixedStrokeWidth: 3, - colorType: "rainbow", - singlePaletteIndex: 0, - gradientPaletteIndex: 0, - rainbowPaletteIndex: 0, - showLabels: false, - showId: false, - opacity: 0.9, - adjustWidthByProperty: true, - }, - legendConfig: { - layerId: "junctions", - layerName: "节点", - property: "压力", // 暂时为空,等计算后更新 - colors: [], - type: "point", - dimensions: [], - breaks: [], - }, - }, - { - isActive: false, // 默认不激活,不显示图例 - layerId: "pipes", - layerName: "管道", - styleConfig: { - property: "flow", - classificationMethod: "pretty_breaks", - segments: 6, - minSize: 4, - maxSize: 12, - minStrokeWidth: 2, - maxStrokeWidth: 8, - fixedStrokeWidth: 3, - colorType: "gradient", - singlePaletteIndex: 0, - gradientPaletteIndex: 0, - rainbowPaletteIndex: 0, - showLabels: false, - showId: false, - opacity: 0.9, - adjustWidthByProperty: true, - }, - legendConfig: { - layerId: "pipes", - layerName: "管道", - property: "流量", // 暂时为空,等计算后更新 - colors: [], - type: "linestring", - dimensions: [], - breaks: [], - }, - }, - ]); + const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>( + () => createDefaultLayerStyleStates() + ); // 计算激活的图例配置 const activeLegendConfigs = layerStyleStates diff --git a/src/components/olmap/core/Controls/styleEditorPresets.ts b/src/components/olmap/core/Controls/styleEditorPresets.ts new file mode 100644 index 0000000..fcda8b9 --- /dev/null +++ b/src/components/olmap/core/Controls/styleEditorPresets.ts @@ -0,0 +1,200 @@ +import { LayerStyleState, StyleConfig, DefaultLayerStyleId } from "./styleEditorTypes"; + +export const SINGLE_COLOR_PALETTES = [ + { color: "rgba(51, 153, 204, 1)" }, + { color: "rgba(255, 138, 92, 1)" }, + { color: "rgba(204, 51, 51, 1)" }, + { color: "rgba(255, 235, 59, 1)" }, + { color: "rgba(44, 160, 44, 1)" }, + { color: "rgba(227, 119, 194, 1)" }, + { color: "rgba(148, 103, 189, 1)" }, +]; + +export const GRADIENT_PALETTES = [ + { + name: "蓝-红", + start: "rgba(51, 153, 204, 1)", + end: "rgba(204, 51, 51, 1)", + }, + { + name: "黄-绿", + start: "rgba(255, 235, 59, 1)", + end: "rgba(44, 160, 44, 1)", + }, + { + name: "粉-紫", + start: "rgba(227, 119, 194, 1)", + end: "rgba(148, 103, 189, 1)", + }, +]; + +export const RAINBOW_PALETTES = [ + { + name: "正向彩虹", + colors: [ + "rgba(255, 0, 0, 1)", + "rgba(255, 127, 0, 1)", + "rgba(255, 215, 0, 1)", + "rgba(199, 224, 0, 1)", + "rgba(76, 175, 80, 1)", + "rgba(0, 158, 115, 1)", + "rgba(0, 188, 212, 1)", + "rgba(33, 150, 243, 1)", + "rgba(63, 81, 181, 1)", + "rgba(142, 68, 173, 1)", + ], + }, + { + name: "反向彩虹", + colors: [ + "rgba(142, 68, 173, 1)", + "rgba(63, 81, 181, 1)", + "rgba(33, 150, 243, 1)", + "rgba(0, 188, 212, 1)", + "rgba(0, 158, 115, 1)", + "rgba(76, 175, 80, 1)", + "rgba(199, 224, 0, 1)", + "rgba(255, 215, 0, 1)", + "rgba(255, 127, 0, 1)", + "rgba(255, 0, 0, 1)", + ], + }, +]; + +export const CLASSIFICATION_METHODS = [ + { name: "优雅分段", value: "pretty_breaks" }, + { name: "自定义", value: "custom_breaks" }, +]; + +export const COLOR_TYPE_OPTIONS = [ + { label: "单一色", value: "single" }, + { label: "渐进色", value: "gradient" }, + { label: "离散彩虹", value: "rainbow" }, + { label: "自定义", value: "custom" }, +]; + +const DEFAULT_LAYER_STYLE_PRESETS: Record< + DefaultLayerStyleId, + Omit<LayerStyleState, "isActive"> +> = { + junctions: { + layerId: "junctions", + layerName: "节点", + styleConfig: { + property: "pressure", + classificationMethod: "custom_breaks", + customBreaks: [16, 18, 20, 22, 24, 26], + customColors: [ + "rgba(255, 0, 0, 1)", + "rgba(255, 127, 0, 1)", + "rgba(255, 215, 0, 1)", + "rgba(199, 224, 0, 1)", + "rgba(76, 175, 80, 1)", + "rgba(0, 158, 115, 1)", + ], + segments: 6, + minSize: 4, + maxSize: 12, + minStrokeWidth: 2, + maxStrokeWidth: 8, + fixedStrokeWidth: 3, + colorType: "rainbow", + singlePaletteIndex: 0, + gradientPaletteIndex: 0, + rainbowPaletteIndex: 0, + showLabels: true, + showId: false, + opacity: 0.9, + adjustWidthByProperty: true, + }, + legendConfig: { + layerId: "junctions", + layerName: "节点", + property: "压力", + colors: [], + type: "point", + dimensions: [], + breaks: [], + }, + }, + pipes: { + layerId: "pipes", + layerName: "管道", + styleConfig: { + property: "velocity", + classificationMethod: "custom_breaks", + segments: 6, + minSize: 4, + maxSize: 12, + minStrokeWidth: 2, + maxStrokeWidth: 8, + fixedStrokeWidth: 3, + colorType: "gradient", + singlePaletteIndex: 0, + gradientPaletteIndex: 0, + rainbowPaletteIndex: 0, + showLabels: true, + showId: false, + opacity: 0.9, + adjustWidthByProperty: true, + customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2], + customColors: [], + }, + legendConfig: { + layerId: "pipes", + layerName: "管道", + property: "流速", + colors: [], + type: "linestring", + dimensions: [], + breaks: [], + }, + }, +}; + +export const createEmptyStyleConfig = (): StyleConfig => ({ + property: "", + classificationMethod: "pretty_breaks", + segments: 5, + minSize: 4, + maxSize: 12, + minStrokeWidth: 2, + maxStrokeWidth: 6, + fixedStrokeWidth: 3, + colorType: "single", + singlePaletteIndex: 0, + gradientPaletteIndex: 0, + rainbowPaletteIndex: 0, + showLabels: false, + showId: false, + opacity: 0.9, + adjustWidthByProperty: true, + customBreaks: [], + customColors: [], +}); + +export const createDefaultLayerStyleState = ( + layerId: DefaultLayerStyleId +): LayerStyleState => { + const preset = DEFAULT_LAYER_STYLE_PRESETS[layerId]; + return { + ...preset, + styleConfig: { + ...preset.styleConfig, + customBreaks: [...(preset.styleConfig.customBreaks || [])], + customColors: [...(preset.styleConfig.customColors || [])], + }, + legendConfig: { + ...preset.legendConfig, + colors: [...preset.legendConfig.colors], + dimensions: [...preset.legendConfig.dimensions], + breaks: [...preset.legendConfig.breaks], + }, + isActive: false, + }; +}; + +export const createDefaultLayerStyleStates = (): LayerStyleState[] => [ + createDefaultLayerStyleState("junctions"), + createDefaultLayerStyleState("pipes"), +]; diff --git a/src/components/olmap/core/Controls/styleEditorTypes.ts b/src/components/olmap/core/Controls/styleEditorTypes.ts new file mode 100644 index 0000000..034bce7 --- /dev/null +++ b/src/components/olmap/core/Controls/styleEditorTypes.ts @@ -0,0 +1,62 @@ +import React from "react"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; + +import { LegendStyleConfig } from "./StyleLegend"; + +export interface StyleConfig { + property: string; + classificationMethod: string; + segments: number; + minSize: number; + maxSize: number; + minStrokeWidth: number; + maxStrokeWidth: number; + fixedStrokeWidth: number; + colorType: string; + singlePaletteIndex: number; + gradientPaletteIndex: number; + rainbowPaletteIndex: number; + showLabels: boolean; + showId: boolean; + opacity: number; + adjustWidthByProperty: boolean; + customBreaks?: number[]; + customColors?: string[]; +} + +export interface LayerStyleState { + layerId: string; + layerName: string; + styleConfig: StyleConfig; + legendConfig: LegendStyleConfig; + isActive: boolean; +} + +export type DefaultLayerStyleId = "junctions" | "pipes"; + +export interface StyleEditorPanelProps { + layerStyleStates: LayerStyleState[]; + setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>; +} + +export interface AvailableProperty { + name: string; + value: string; +} + +export interface StyleEditorFormProps { + renderLayers: WebGLVectorTileLayer[]; + selectedRenderLayer?: WebGLVectorTileLayer; + styleConfig: StyleConfig; + setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>; + availableProperties: AvailableProperty[]; + onLayerChange: (index: number) => void; + onPropertyChange: (property: string) => void; + onClassificationMethodChange: (method: string) => void; + onSegmentsChange: (segments: number) => void; + onCustomBreakChange: (index: number, value: string) => void; + onCustomBreakBlur: () => void; + onColorTypeChange: (colorType: string) => void; + onApply: () => void; + onReset: () => void; +} diff --git a/src/components/olmap/core/Controls/styleEditorUtils.ts b/src/components/olmap/core/Controls/styleEditorUtils.ts new file mode 100644 index 0000000..4e6f24c --- /dev/null +++ b/src/components/olmap/core/Controls/styleEditorUtils.ts @@ -0,0 +1,348 @@ +import { FlatStyleLike } from "ol/style/flat"; + +import { calculateClassification } from "@utils/breaks_classification"; +import { parseColor } from "@utils/parseColor"; + +import { + GRADIENT_PALETTES, + RAINBOW_PALETTES, + SINGLE_COLOR_PALETTES, +} from "./styleEditorPresets"; +import { StyleConfig } from "./styleEditorTypes"; + +export const rgbaToHex = (rgba: string) => { + try { + const c = parseColor(rgba); + const toHex = (n: number) => { + const hex = Math.round(n).toString(16); + return hex.length === 1 ? `0${hex}` : hex; + }; + return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`; + } catch { + return "#000000"; + } +}; + +export const hexToRgba = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt( + result[3], + 16 + )}, 1)` + : "rgba(0, 0, 0, 1)"; +}; + +export const getDefaultCustomColors = ( + segments: number, + existingColors: string[] = [] +) => { + const nextColors = [...existingColors]; + const baseColors = RAINBOW_PALETTES[0].colors; + + while (nextColors.length < segments) { + nextColors.push(baseColors[nextColors.length % baseColors.length]); + } + + return nextColors.slice(0, segments); +}; + +export const getDefaultCustomBreaks = ({ + segments, + property, + layerId, + elevationRange, + diameterRange, + currentJunctionCalData, + currentPipeCalData, +}: { + segments: number; + property: string; + layerId?: string; + elevationRange?: [number, number]; + diameterRange?: [number, number]; + currentJunctionCalData?: any[]; + currentPipeCalData?: any[]; +}) => { + if (!layerId || !property) { + return Array.from({ length: segments }, () => 0); + } + + let dataArr: number[] = []; + + const isElevation = layerId === "junctions" && property === "elevation"; + const isDiameter = layerId === "pipes" && property === "diameter"; + + if (isElevation && elevationRange) { + dataArr = [elevationRange[0], elevationRange[1]]; + } else if (isDiameter && diameterRange) { + dataArr = [diameterRange[0], diameterRange[1]]; + } else if (layerId === "junctions" && currentJunctionCalData) { + dataArr = currentJunctionCalData.map((d: any) => d.value); + } else if (layerId === "pipes" && currentPipeCalData) { + dataArr = currentPipeCalData.map((d: any) => d.value); + } + + if (dataArr.length === 0) { + return Array.from({ length: segments }, () => 0); + } + + const defaultBreaks = calculateClassification( + dataArr, + segments, + "pretty_breaks" + ).slice(0, segments); + + while (defaultBreaks.length < segments) { + defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0); + } + + return defaultBreaks; +}; + +export const normalizeCustomBreaks = (breaks: number[], desired: number) => { + const nextBreaks = [...breaks] + .slice(0, desired) + .filter((value) => value >= 0) + .sort((a, b) => a - b); + + while (nextBreaks.length < desired) { + nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0); + } + + return nextBreaks; +}; + +export const addBreakExtrema = (breaks: number[], dataValues: number[]) => { + const nextBreaks = [...breaks]; + const minValue = Math.max( + dataValues.reduce((min, value) => Math.min(min, value), Infinity), + 0 + ); + const maxValue = dataValues.reduce( + (max, value) => Math.max(max, value), + -Infinity + ); + + if (!nextBreaks.includes(minValue)) { + nextBreaks.push(minValue); + } + + if (!nextBreaks.includes(maxValue)) { + nextBreaks.push(maxValue); + } + + nextBreaks.sort((a, b) => a - b); + return nextBreaks; +}; + +export const resolveStyleColors = ( + styleConfig: StyleConfig, + breaksLength: number +): string[] => { + if (styleConfig.colorType === "single") { + return Array.from( + { length: breaksLength }, + () => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color + ); + } + + if (styleConfig.colorType === "gradient") { + const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; + const startColor = parseColor(start); + const endColor = parseColor(end); + + return Array.from({ length: breaksLength }, (_, index) => { + const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1; + const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio); + const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio); + const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio); + return `rgba(${r}, ${g}, ${b}, 1)`; + }); + } + + if (styleConfig.colorType === "rainbow") { + const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; + return Array.from( + { length: breaksLength }, + (_, index) => baseColors[index % baseColors.length] + ); + } + + const customColors = styleConfig.customColors || []; + const reverseRainbowColors = RAINBOW_PALETTES[1].colors; + const result = [...customColors]; + + while (result.length < breaksLength) { + result.push( + reverseRainbowColors[ + (result.length - customColors.length) % reverseRainbowColors.length + ] + ); + } + + return result.slice(0, breaksLength); +}; + +export const getSizePreviewColors = (styleConfig: StyleConfig) => { + if (styleConfig.colorType === "single") { + const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color; + return [color, color]; + } + + if (styleConfig.colorType === "gradient") { + const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; + return [start, end]; + } + + if (styleConfig.colorType === "rainbow") { + const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; + return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]]; + } + + const customColors = styleConfig.customColors || []; + return [ + customColors[0] || "rgba(0,0,0,1)", + customColors[customColors.length - 1] || "rgba(0,0,0,1)", + ]; +}; + +export const resolveDimensions = ({ + layerType, + styleConfig, + breaksLength, +}: { + layerType: string; + styleConfig: StyleConfig; + breaksLength: number; +}) => { + if (layerType === "linestring") { + if (styleConfig.adjustWidthByProperty) { + return Array.from({ length: breaksLength }, (_, index) => { + const ratio = index / (breaksLength - 1); + return ( + styleConfig.minStrokeWidth + + (styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio + ); + }); + } + + return Array.from( + { length: breaksLength }, + () => styleConfig.fixedStrokeWidth + ); + } + + return Array.from({ length: breaksLength }, (_, index) => { + const ratio = index / (breaksLength - 1); + return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio; + }); +}; + +export const buildDynamicStyle = ({ + layerType, + styleConfig, + breaks, + colors, + dimensions, +}: { + layerType: string; + styleConfig: StyleConfig; + breaks: number[]; + colors: string[]; + dimensions: number[]; +}): FlatStyleLike => { + const generateColorConditions = (property: string): any[] => { + const conditions: any[] = ["case"]; + for (let index = 1; index < breaks.length; index++) { + if (property === "unit_headloss") { + conditions.push([ + "<=", + ["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]], + breaks[index], + ]); + } else { + conditions.push(["<=", ["get", property], breaks[index]]); + } + const colorObj = parseColor(colors[index - 1]); + conditions.push( + `rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})` + ); + } + const defaultColor = parseColor(colors[0]); + conditions.push( + `rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})` + ); + return conditions; + }; + + const generateDimensionConditions = (property: string): any[] => { + const conditions: any[] = ["case"]; + for (let index = 0; index < breaks.length; index++) { + if (property === "unit_headloss") { + conditions.push([ + "<=", + ["/", ["get", "headloss"], ["get", "length"]], + breaks[index], + ]); + } else { + conditions.push(["<=", ["get", property], breaks[index]]); + } + conditions.push(dimensions[index]); + } + conditions.push(dimensions[dimensions.length - 1]); + return conditions; + }; + + const generatePointDimensionConditions = (property: string): any[] => { + const conditions: any[] = ["case"]; + for (let index = 0; index < breaks.length; index++) { + conditions.push(["<=", ["get", property], breaks[index]]); + conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]); + } + conditions.push(dimensions[dimensions.length - 1]); + return conditions; + }; + + const dynamicStyle: FlatStyleLike = {}; + + if (layerType === "linestring") { + dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property); + dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property); + } else if (layerType === "point") { + dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property); + dynamicStyle["circle-radius"] = generatePointDimensionConditions( + styleConfig.property + ); + dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property); + dynamicStyle["circle-stroke-width"] = 2; + } + + return dynamicStyle; +}; + +export const buildContourDefinitions = ({ + styleConfig, + breaks, + colors, +}: { + styleConfig: StyleConfig; + breaks: number[]; + colors: string[]; +}) => { + const contours = []; + for (let index = 0; index < breaks.length - 1; index++) { + const colorObj = parseColor(colors[index]); + contours.push({ + threshold: [breaks[index], breaks[index + 1]], + color: [ + colorObj.r, + colorObj.g, + colorObj.b, + Math.round(styleConfig.opacity * 255), + ], + strokeWidth: 0, + }); + } + return contours; +}; diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts new file mode 100644 index 0000000..1da8a3f --- /dev/null +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -0,0 +1,944 @@ +import { useNotification } from "@refinedev/core"; +import { VectorTile } from "ol"; +import type { Map as OlMap } from "ol"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import VectorTileSource from "ol/source/VectorTile"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FlatStyleLike } from "ol/style/flat"; + +import { config } from "@/config/config"; + +import { useData, useMap } from "../MapComponent"; +import { + createDefaultLayerStyleState, + createEmptyStyleConfig, +} from "./styleEditorPresets"; +import { + addBreakExtrema, + buildContourDefinitions, + buildDynamicStyle, + getDefaultCustomBreaks, + getDefaultCustomColors, + normalizeCustomBreaks, + resolveDimensions, + resolveStyleColors, +} from "./styleEditorUtils"; +import { + AvailableProperty, + LayerStyleState, + StyleEditorPanelProps, +} from "./styleEditorTypes"; +import { LegendStyleConfig } from "./StyleLegend"; +import { calculateClassification } from "@utils/breaks_classification"; + +const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5]; + +export const useStyleEditor = ({ + layerStyleStates, + setLayerStyleStates, +}: StyleEditorPanelProps) => { + const map = useMap(); + const data = useData(); + const { open } = useNotification(); + + const currentJunctionCalData = data?.currentJunctionCalData; + const currentPipeCalData = data?.currentPipeCalData; + const compareJunctionCalData = data?.compareJunctionCalData; + const comparePipeCalData = data?.comparePipeCalData; + const compareMap = data?.compareMap; + const activeMaps = useMemo<OlMap[]>( + () => (data?.maps?.length ? data.maps : map ? [map] : []), + [data?.maps, map] + ); + const junctionText = data?.junctionText ?? ""; + const pipeText = data?.pipeText ?? ""; + const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; + const setShowPipeTextLayer = data?.setShowPipeTextLayer; + const setShowJunctionId = data?.setShowJunctionId; + const setShowPipeId = data?.setShowPipeId; + const setContourLayerAvailable = data?.setContourLayerAvailable; + const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable; + const setJunctionText = data?.setJunctionText; + const setPipeText = data?.setPipeText; + const setContours = data?.setContours; + const diameterRange = data?.diameterRange; + const elevationRange = data?.elevationRange; + const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0; + + const [applyJunctionStyle, setApplyJunctionStyle] = useState(false); + const [applyPipeStyle, setApplyPipeStyle] = useState(false); + const [styleUpdateTrigger, setStyleUpdateTrigger] = useState(0); + const prevStyleUpdateTriggerRef = useRef(0); + const lastForceStyleAutoApplyVersionRef = useRef(0); + + const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]); + const [selectedRenderLayer, setSelectedRenderLayer] = + useState<WebGLVectorTileLayer>(); + const [styleConfig, setStyleConfig] = useState(createEmptyStyleConfig); + const latestLayerStyleStatesRef = useRef(layerStyleStates); + + const tileLoadListenersRef = useRef< + Map<string, { source: VectorTileSource; listener: (event: any) => void }> + >(new Map()); + + useEffect(() => { + latestLayerStyleStatesRef.current = layerStyleStates; + }, [layerStyleStates]); + + const getRenderLayersById = useCallback( + (layerId: string) => + activeMaps.flatMap((targetMap) => + targetMap + .getAllLayers() + .filter((layer) => layer.get("value") === layerId) + .filter( + (layer): layer is WebGLVectorTileLayer => + layer instanceof WebGLVectorTileLayer + ) + ), + [activeMaps] + ); + + const getMapKey = useCallback((targetMap: OlMap, layerId: string) => { + const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map"; + return `${mapUid}:${layerId}`; + }, []); + + const getDataForMap = useCallback( + (targetMap: OlMap, layerId: string) => { + if (layerId === "junctions") { + return targetMap === compareMap + ? compareJunctionCalData || [] + : currentJunctionCalData || []; + } + if (layerId === "pipes") { + return targetMap === compareMap + ? comparePipeCalData || [] + : currentPipeCalData || []; + } + return []; + }, + [ + compareJunctionCalData, + compareMap, + comparePipeCalData, + currentJunctionCalData, + currentPipeCalData, + ] + ); + + const availableProperties = useMemo<AvailableProperty[]>(() => { + if (!selectedRenderLayer) { + return []; + } + + return (selectedRenderLayer.get("properties") || []) as AvailableProperty[]; + }, [selectedRenderLayer]); + + const getBreakDefaults = useCallback( + (segments: number, property: string, layer = selectedRenderLayer) => + getDefaultCustomBreaks({ + segments, + property, + layerId: layer?.get("value"), + elevationRange, + diameterRange, + currentJunctionCalData, + currentPipeCalData, + }), + [ + currentJunctionCalData, + currentPipeCalData, + diameterRange, + elevationRange, + selectedRenderLayer, + ] + ); + + const saveLayerStyle = useCallback( + ( + layerId?: string, + newLegendConfig?: LegendStyleConfig, + overrideStyleConfig = styleConfig + ) => { + if (!overrideStyleConfig.property || !layerId) { + return; + } + + const layerName = + newLegendConfig?.layerName || + selectedRenderLayer?.get("name") || + `图层${layerId}`; + const property = availableProperties.find( + (item) => item.value === overrideStyleConfig.property + ); + + const legendConfig: LegendStyleConfig = newLegendConfig || { + layerId, + layerName, + property: property?.name || overrideStyleConfig.property, + colors: [], + type: selectedRenderLayer?.get("type") || "point", + dimensions: [], + breaks: [], + }; + + const newStyleState: LayerStyleState = { + layerId, + layerName, + styleConfig: { ...overrideStyleConfig }, + legendConfig: { ...legendConfig }, + isActive: true, + }; + + setLayerStyleStates((prev) => { + const existingIndex = prev.findIndex((state) => state.layerId === layerId); + if (existingIndex !== -1) { + const existingState = prev[existingIndex]; + if ( + JSON.stringify(existingState.styleConfig) === + JSON.stringify(newStyleState.styleConfig) && + JSON.stringify(existingState.legendConfig) === + JSON.stringify(newStyleState.legendConfig) && + existingState.layerName === newStyleState.layerName && + existingState.isActive === newStyleState.isActive + ) { + return prev; + } + const updated = [...prev]; + updated[existingIndex] = newStyleState; + return updated; + } + return [...prev, newStyleState]; + }); + }, + [availableProperties, selectedRenderLayer, setLayerStyleStates, styleConfig] + ); + + const applyContourLayerStyle = useCallback( + (layerStyleConfig: LayerStyleState, breaks?: number[]) => { + if (!breaks || breaks.length === 0 || !setContours) { + return; + } + + const colors = resolveStyleColors(layerStyleConfig.styleConfig, breaks.length); + setContours( + buildContourDefinitions({ + styleConfig: layerStyleConfig.styleConfig, + breaks, + colors, + }) + ); + }, + [setContours] + ); + + const applyLayerStyle = useCallback( + (layerStyleConfig: LayerStyleState, breaks?: number[]) => { + if (!breaks || breaks.length === 0) { + return; + } + + const nextStyleConfig = layerStyleConfig.styleConfig; + const targetLayers = getRenderLayersById(layerStyleConfig.layerId); + const renderLayer = targetLayers[0]; + if (!renderLayer || !nextStyleConfig.property) { + return; + } + + const layerType = renderLayer.get("type") as string; + const colors = resolveStyleColors(nextStyleConfig, breaks.length); + const dimensions = resolveDimensions({ + layerType, + styleConfig: nextStyleConfig, + breaksLength: breaks.length, + }); + const dynamicStyle = buildDynamicStyle({ + layerType, + styleConfig: nextStyleConfig, + breaks, + colors, + dimensions, + }); + + targetLayers.forEach((targetLayer) => { + targetLayer.setStyle(dynamicStyle); + }); + + const layerId = renderLayer.get("value"); + const initLayerStyleState = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === layerId + ); + const legendConfig: LegendStyleConfig = { + layerName: initLayerStyleState?.layerName || `图层${layerId}`, + layerId, + property: initLayerStyleState?.legendConfig.property || "", + colors, + type: layerType, + dimensions, + breaks, + }; + + setTimeout(() => { + saveLayerStyle(layerId, legendConfig, nextStyleConfig); + }, 100); + }, + [getRenderLayersById, saveLayerStyle] + ); + + const applyClassificationStyle = useCallback( + (layerType: "junctions" | "pipes", fallbackStyleConfig?: LayerStyleState["styleConfig"]) => { + const layerStyleState = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === layerType + ); + const effectiveStyleConfig = layerStyleState?.styleConfig || fallbackStyleConfig; + + if (!effectiveStyleConfig) { + return; + } + + const isElevation = + layerType === "junctions" && effectiveStyleConfig.property === "elevation"; + const isDiameter = + layerType === "pipes" && effectiveStyleConfig.property === "diameter"; + const isUnitHeadloss = + layerType === "pipes" && effectiveStyleConfig.property === "unit_headloss"; + + const dataValues = + layerType === "junctions" + ? isElevation && elevationRange + ? [elevationRange[0], elevationRange[1]] + : currentJunctionCalData?.map((item: any) => item.value) || [] + : isDiameter && diameterRange + ? [diameterRange[0], diameterRange[1]] + : isUnitHeadloss + ? [UNIT_HEADLOSS_RANGE[0], UNIT_HEADLOSS_RANGE[1]] + : currentPipeCalData?.map((item: any) => item.value) || []; + + const canApply = + layerType === "junctions" + ? dataValues.length > 0 + : dataValues.length > 0 || isUnitHeadloss; + + if (!canApply || dataValues.length === 0) { + return; + } + + const segments = effectiveStyleConfig.segments ?? 5; + let breaks = + effectiveStyleConfig.classificationMethod === "custom_breaks" + ? normalizeCustomBreaks(effectiveStyleConfig.customBreaks || [], segments) + : calculateClassification( + dataValues, + segments, + effectiveStyleConfig.classificationMethod + ); + + if (breaks.length === 0) { + return; + } + + breaks = addBreakExtrema(breaks, dataValues); + + const styleStateToApply = + layerStyleState || + ({ + layerId: layerType, + layerName: layerType === "junctions" ? "节点" : "管道", + styleConfig: effectiveStyleConfig, + legendConfig: { + layerId: layerType, + layerName: layerType === "junctions" ? "节点" : "管道", + property: effectiveStyleConfig.property, + colors: [], + type: layerType === "junctions" ? "point" : "linestring", + dimensions: [], + breaks: [], + }, + isActive: true, + } as LayerStyleState); + + applyLayerStyle(styleStateToApply, breaks); + if (layerType === "junctions") { + applyContourLayerStyle(styleStateToApply, breaks); + } + }, + [ + applyContourLayerStyle, + applyLayerStyle, + currentJunctionCalData, + currentPipeCalData, + diameterRange, + elevationRange, + ] + ); + + const updateVectorTileSource = useCallback( + (targetMap: OlMap, layerId: string, property: string, records: any[]) => { + const vectorTileSources = targetMap + .getAllLayers() + .filter((layer) => layer.get("value") === layerId) + .map((layer) => layer.getSource() as VectorTileSource) + .filter((source) => source); + + if (!vectorTileSources.length) { + return; + } + + const dataMap = new Map<string, number>(); + records.forEach((record: any) => { + dataMap.set(record.ID, record.value || 0); + }); + + vectorTileSources.forEach((vectorTileSource) => { + const sourceTiles = (vectorTileSource as any).sourceTiles_; + Object.values(sourceTiles).forEach((vectorTile: any) => { + const renderFeatures = vectorTile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) { + return; + } + + renderFeatures.forEach((renderFeature: any) => { + const featureId = renderFeature.get("id"); + const value = dataMap.get(featureId); + if (value === undefined) { + return; + } + + renderFeature.properties_[property] = + property === "flow" ? Math.abs(value) : value; + }); + }); + }); + }, + [] + ); + + const attachVectorTileSourceLoadedEvent = useCallback( + (targetMap: OlMap, layerId: string, property: string, records: any[]) => { + const vectorTileSource = targetMap + .getAllLayers() + .filter((layer) => layer.get("value") === layerId) + .map((layer) => layer.getSource() as VectorTileSource) + .filter((source) => source)[0]; + + if (!vectorTileSource) { + return; + } + + const dataMap = new Map<string, number>(); + records.forEach((record: any) => { + dataMap.set(record.ID, record.value || 0); + }); + + const listener = (event: any) => { + try { + if (!(event.tile instanceof VectorTile)) { + return; + } + + const renderFeatures = event.tile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) { + return; + } + + renderFeatures.forEach((renderFeature: any) => { + const featureId = renderFeature.get("id"); + const value = dataMap.get(featureId); + if (value === undefined) { + return; + } + + renderFeature.properties_[property] = + property === "flow" ? Math.abs(value) : value; + }); + } catch (error) { + console.error("Error processing tile load event:", error); + } + }; + + const listenerKey = getMapKey(targetMap, layerId); + vectorTileSource.on("tileloadend", listener); + tileLoadListenersRef.current.set(listenerKey, { + source: vectorTileSource, + listener, + }); + }, + [getMapKey] + ); + + const removeVectorTileSourceLoadedEvent = useCallback( + (targetMap: OlMap, layerId: string) => { + const listenerKey = getMapKey(targetMap, layerId); + const listenerState = tileLoadListenersRef.current.get(listenerKey); + if (listenerState) { + listenerState.source.un("tileloadend", listenerState.listener); + tileLoadListenersRef.current.delete(listenerKey); + } + }, + [getMapKey] + ); + + const handleApply = useCallback(() => { + if (!selectedRenderLayer || !styleConfig.property) { + return; + } + + const layerId = selectedRenderLayer.get("value"); + const property = styleConfig.property; + + if (styleConfig.classificationMethod === "custom_breaks") { + const expected = styleConfig.segments; + const custom = styleConfig.customBreaks || []; + + if ( + custom.length !== expected || + custom.some((value) => value === undefined || value === null || isNaN(value)) + ) { + open?.({ + type: "error", + message: `请设置 ${expected} 个有效的自定义阈值(数字)`, + }); + return; + } + + if (custom.some((value) => value < 0)) { + open?.({ type: "error", message: "自定义阈值必须大于等于 0" }); + return; + } + + setStyleConfig((prev) => ({ + ...prev, + customBreaks: [...(prev.customBreaks || [])] + .slice(0, expected) + .sort((a, b) => a - b), + })); + } + + if (layerId === "junctions") { + setJunctionText?.(property); + setShowJunctionTextLayer?.(styleConfig.showLabels); + setShowJunctionId?.(styleConfig.showId); + setApplyJunctionStyle(true); + if (property === "pressure") { + setContourLayerAvailable?.(true); + } + saveLayerStyle(layerId); + open?.({ + type: "success", + message: "节点图层样式设置成功,等待数据更新。", + }); + } + + if (layerId === "pipes") { + setPipeText?.(property); + setShowPipeTextLayer?.(styleConfig.showLabels); + setShowPipeId?.(styleConfig.showId); + setApplyPipeStyle(true); + setWaterflowLayerAvailable?.(true); + saveLayerStyle(layerId); + open?.({ + type: "success", + message: "管道图层样式设置成功,等待数据更新。", + }); + } + + setStyleUpdateTrigger((prev) => prev + 1); + }, [ + open, + saveLayerStyle, + selectedRenderLayer, + setContourLayerAvailable, + setJunctionText, + setPipeText, + setShowJunctionId, + setShowJunctionTextLayer, + setShowPipeId, + setShowPipeTextLayer, + setWaterflowLayerAvailable, + styleConfig, + ]); + + const handleReset = useCallback(() => { + if (!selectedRenderLayer) { + return; + } + + const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; + const layerId = selectedRenderLayer.get("value"); + + getRenderLayersById(layerId).forEach((targetLayer) => { + targetLayer.setStyle(defaultFlatStyle); + }); + + setLayerStyleStates((prev) => prev.filter((state) => state.layerId !== layerId)); + + if (layerId === "junctions") { + setApplyJunctionStyle(false); + setShowJunctionTextLayer?.(false); + setShowJunctionId?.(false); + setJunctionText?.(""); + setContours?.([]); + setContourLayerAvailable?.(false); + } else if (layerId === "pipes") { + setApplyPipeStyle(false); + setShowPipeTextLayer?.(false); + setShowPipeId?.(false); + setPipeText?.(""); + setWaterflowLayerAvailable?.(false); + } + }, [ + getRenderLayersById, + selectedRenderLayer, + setContourLayerAvailable, + setContours, + setJunctionText, + setLayerStyleStates, + setPipeText, + setShowJunctionId, + setShowJunctionTextLayer, + setShowPipeId, + setShowPipeTextLayer, + setWaterflowLayerAvailable, + ]); + + const handleLayerChange = useCallback( + (index: number) => { + const newLayer = index >= 0 ? renderLayers[index] : undefined; + setSelectedRenderLayer(newLayer); + + if (!newLayer) { + return; + } + + const layerId = newLayer.get("value"); + const cachedStyleState = layerStyleStates.find((state) => state.layerId === layerId); + + if (cachedStyleState) { + setStyleConfig(cachedStyleState.styleConfig); + return; + } + + setStyleConfig((prev) => ({ + ...prev, + property: "", + customBreaks: + prev.classificationMethod === "custom_breaks" + ? getBreakDefaults(prev.segments, "", newLayer) + : prev.customBreaks, + customColors: getDefaultCustomColors(prev.segments, prev.customColors), + })); + }, + [getBreakDefaults, layerStyleStates, renderLayers] + ); + + const handlePropertyChange = useCallback( + (property: string) => { + setStyleConfig((prev) => ({ + ...prev, + property, + customBreaks: + prev.classificationMethod === "custom_breaks" + ? getBreakDefaults(prev.segments, property) + : prev.customBreaks, + })); + }, + [getBreakDefaults] + ); + + const handleClassificationMethodChange = useCallback( + (classificationMethod: string) => { + setStyleConfig((prev) => ({ + ...prev, + classificationMethod, + customBreaks: + classificationMethod === "custom_breaks" + ? getBreakDefaults(prev.segments, prev.property) + : prev.customBreaks, + })); + }, + [getBreakDefaults] + ); + + const handleSegmentsChange = useCallback( + (segments: number) => { + setStyleConfig((prev) => { + const newCustomColors = [...(prev.customColors || [])]; + return { + ...prev, + segments, + customBreaks: + prev.classificationMethod === "custom_breaks" + ? getBreakDefaults(segments, prev.property) + : prev.customBreaks, + customColors: getDefaultCustomColors(segments, newCustomColors), + }; + }); + }, + [getBreakDefaults] + ); + + const handleCustomBreakChange = useCallback( + (index: number, value: string) => { + const nextValue = parseFloat(value); + setStyleConfig((prev) => { + const nextBreaks = [...(prev.customBreaks || [])]; + while (nextBreaks.length < prev.segments) { + nextBreaks.push(0); + } + nextBreaks[index] = isNaN(nextValue) ? 0 : Math.max(0, nextValue); + return { ...prev, customBreaks: nextBreaks }; + }); + }, + [] + ); + + const handleCustomBreakBlur = useCallback(() => { + setStyleConfig((prev) => ({ + ...prev, + customBreaks: [...(prev.customBreaks || [])] + .slice(0, prev.segments + 1) + .sort((a, b) => a - b), + })); + }, []); + + const handleColorTypeChange = useCallback((colorType: string) => { + setStyleConfig((prev) => { + let customColors = prev.customColors; + if (colorType === "custom" && (!customColors || customColors.length === 0)) { + customColors = getDefaultCustomColors(prev.segments, []); + } + + return { + ...prev, + colorType, + adjustWidthByProperty: colorType === "single" ? true : prev.adjustWidthByProperty, + customColors, + }; + }); + }, []); + + useEffect(() => { + if ( + forceStyleAutoApplyVersion <= 0 || + forceStyleAutoApplyVersion === lastForceStyleAutoApplyVersionRef.current + ) { + return; + } + + lastForceStyleAutoApplyVersionRef.current = forceStyleAutoApplyVersion; + + const defaultJunctionStyleState = { + ...createDefaultLayerStyleState("junctions"), + isActive: true, + }; + const defaultPipeStyleState = { + ...createDefaultLayerStyleState("pipes"), + isActive: true, + }; + + setLayerStyleStates((prev) => { + const nextStates = [...prev]; + [defaultJunctionStyleState, defaultPipeStyleState].forEach((defaultState) => { + const index = nextStates.findIndex((state) => state.layerId === defaultState.layerId); + if (index === -1) { + nextStates.push(defaultState); + } else { + nextStates[index] = defaultState; + } + }); + return nextStates; + }); + + setJunctionText?.(defaultJunctionStyleState.styleConfig.property); + setPipeText?.(defaultPipeStyleState.styleConfig.property); + setShowJunctionTextLayer?.(defaultJunctionStyleState.styleConfig.showLabels); + setShowPipeTextLayer?.(defaultPipeStyleState.styleConfig.showLabels); + setShowJunctionId?.(defaultJunctionStyleState.styleConfig.showId); + setShowPipeId?.(defaultPipeStyleState.styleConfig.showId); + setContourLayerAvailable?.( + defaultJunctionStyleState.styleConfig.property === "pressure" + ); + setWaterflowLayerAvailable?.( + defaultPipeStyleState.styleConfig.property === "flow" + ); + setApplyJunctionStyle(true); + setApplyPipeStyle(true); + + const selectedLayerId = selectedRenderLayer?.get("value"); + if (selectedLayerId === "junctions") { + setStyleConfig(defaultJunctionStyleState.styleConfig); + } else if (selectedLayerId === "pipes") { + setStyleConfig(defaultPipeStyleState.styleConfig); + } + }, [ + forceStyleAutoApplyVersion, + selectedRenderLayer, + setContourLayerAvailable, + setJunctionText, + setLayerStyleStates, + setPipeText, + setShowJunctionId, + setShowJunctionTextLayer, + setShowPipeId, + setShowPipeTextLayer, + setWaterflowLayerAvailable, + ]); + + useEffect(() => { + const isUserTrigger = styleUpdateTrigger !== prevStyleUpdateTriggerRef.current; + prevStyleUpdateTriggerRef.current = styleUpdateTrigger; + + const updateJunctionStyle = () => { + const junctionStyleState = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === "junctions" + ); + const isElevation = + junctionStyleState?.styleConfig.property === "elevation"; + + applyClassificationStyle("junctions", junctionStyleState?.styleConfig); + + if (isElevation) { + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + }); + return; + } + + activeMaps.forEach((targetMap) => { + const targetData = getDataForMap(targetMap, "junctions"); + if (!targetData || targetData.length === 0) { + return; + } + updateVectorTileSource(targetMap, "junctions", junctionText, targetData); + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + attachVectorTileSourceLoadedEvent( + targetMap, + "junctions", + junctionText, + targetData + ); + }); + }; + + const updatePipeStyle = () => { + const pipeStyleState = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === "pipes" + ); + const isDiameter = pipeStyleState?.styleConfig.property === "diameter"; + + applyClassificationStyle("pipes", pipeStyleState?.styleConfig); + + if (isDiameter) { + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + }); + return; + } + + activeMaps.forEach((targetMap) => { + const targetData = getDataForMap(targetMap, "pipes"); + if (!targetData || targetData.length === 0) { + return; + } + updateVectorTileSource(targetMap, "pipes", pipeText, targetData); + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + attachVectorTileSourceLoadedEvent(targetMap, "pipes", pipeText, targetData); + }); + }; + + if (isUserTrigger) { + if (selectedRenderLayer?.get("value") === "junctions") { + updateJunctionStyle(); + } else if (selectedRenderLayer?.get("value") === "pipes") { + updatePipeStyle(); + } + return; + } + + const isElevation = junctionText === "elevation"; + const isDiameter = pipeText === "diameter"; + + if ( + applyJunctionStyle && + ((currentJunctionCalData && currentJunctionCalData.length > 0) || isElevation) + ) { + updateJunctionStyle(); + } + + if ( + applyPipeStyle && + ((currentPipeCalData && currentPipeCalData.length > 0) || isDiameter) + ) { + updatePipeStyle(); + } + + if (!applyJunctionStyle) { + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + }); + } + + if (!applyPipeStyle) { + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + }); + } + // This effect is intentionally driven by explicit style triggers and data snapshots. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + styleUpdateTrigger, + applyJunctionStyle, + applyPipeStyle, + currentJunctionCalData, + currentPipeCalData, + compareJunctionCalData, + comparePipeCalData, + activeMaps, + ]); + + useEffect(() => { + return () => { + activeMaps.forEach((targetMap) => { + removeVectorTileSourceLoadedEvent(targetMap, "junctions"); + removeVectorTileSourceLoadedEvent(targetMap, "pipes"); + }); + }; + }, [activeMaps, removeVectorTileSourceLoadedEvent]); + + useEffect(() => { + if (!map) { + return; + } + + const updateVisibleLayers = () => { + const layers = map.getAllLayers(); + const webGLVectorTileLayers = layers.filter( + (layer) => + layer.get("value") === "junctions" || layer.get("value") === "pipes" + ) as WebGLVectorTileLayer[]; + + setRenderLayers(webGLVectorTileLayers); + }; + + updateVisibleLayers(); + }, [map]); + + return { + isReady: Boolean(data), + renderLayers, + selectedRenderLayer, + styleConfig, + setStyleConfig, + availableProperties, + handleLayerChange, + handlePropertyChange, + handleClassificationMethodChange, + handleSegmentsChange, + handleCustomBreakChange, + handleCustomBreakBlur, + handleColorTypeChange, + handleApply, + handleReset, + }; +}; diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 64ad653..b8eca89 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -85,6 +85,8 @@ interface DataContextType { maps?: OlMap[]; diameterRange?: [number, number]; elevationRange?: [number, number]; + forceStyleAutoApplyVersion?: number; + setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>; } // 跨组件传递 @@ -184,7 +186,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const [showPipeId, setShowPipeId] = useState(false); // 控制管道ID显示 const [showContourLayer, setShowContourLayer] = useState(false); // 控制等高线图层显示 const [junctionText, setJunctionText] = useState("pressure"); - const [pipeText, setPipeText] = useState("flow"); + const [pipeText, setPipeText] = useState("velocity"); const [contours, setContours] = useState<any[]>([]); const flowAnimation = useRef(false); // 添加动画控制标志 const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示 @@ -263,6 +265,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const [elevationRange, setElevationRange] = useState< [number, number] | undefined >(); + const [forceStyleAutoApplyVersion, setForceStyleAutoApplyVersion] = + useState(0); const toggleCompareMode = useCallback(() => { setCompareMode((prev) => !prev); @@ -1526,6 +1530,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { maps, diameterRange, elevationRange, + forceStyleAutoApplyVersion, + setForceStyleAutoApplyVersion, }} > <MapContext.Provider value={map}> -- 2.54.0 From 9761ade8d8482895d52c59eb0ae3013b8b378dec Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 29 May 2026 10:27:27 +0800 Subject: [PATCH 152/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=20agent=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/ChatToolCallBlock.tsx | 27 ++ .../chat/hooks/useAgentToolActions.ts | 22 ++ src/components/chat/toolCallStyleHelpers.ts | 150 +++++++++ .../olmap/core/Controls/StyleEditorPanel.tsx | 57 ++-- .../olmap/core/Controls/Toolbar.tsx | 35 ++- .../olmap/core/Controls/styleEditorTypes.ts | 6 +- .../olmap/core/Controls/useStyleEditor.ts | 290 ++++++++++++++++-- .../core/Controls/useToolbarChatActions.ts | 23 ++ src/store/chatToolStore.ts | 8 + 9 files changed, 549 insertions(+), 69 deletions(-) create mode 100644 src/components/chat/toolCallStyleHelpers.ts diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index a7f2c79..a552a00 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -25,6 +25,11 @@ import { type ChatToolAction, } from "@/store/chatToolStore"; import type { ToolCall } from "./chatMessageSections"; +import { + APPLY_LAYER_STYLE_TOOL, + describeApplyLayerStyle, + parseApplyLayerStylePayload, +} from "./toolCallStyleHelpers"; /* ------------------------------------------------------------------ */ /* Interactive card rendered inside a chat bubble for tool actions */ @@ -137,6 +142,12 @@ const TOOL_META: Record<string, ToolMeta> = { actionLabel: "应用渲染", color: "#3b82f6", }, + [APPLY_LAYER_STYLE_TOOL]: { + label: "图层样式", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "应用样式", + color: "#14b8a6", + }, }; /* ---------- helpers ---------- */ @@ -270,6 +281,10 @@ function getToolDescription(toolCall: ToolCall): string { case "render_junctions": { return (params.render_ref as string | undefined) ?? "渲染引用"; } + case APPLY_LAYER_STYLE_TOOL: { + const payload = parseApplyLayerStylePayload(params); + return payload ? describeApplyLayerStyle(payload) : "图层样式"; + } default: return ""; } @@ -403,6 +418,18 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { renderRef, }; } + case APPLY_LAYER_STYLE_TOOL: { + const payload = parseApplyLayerStylePayload(params); + if (!payload) { + return null; + } + return { + type: "apply_layer_style", + layerId: payload.layerId, + resetToDefault: payload.resetToDefault, + styleConfig: payload.styleConfig, + }; + } default: return null; } diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts index e8bd878..ff9191a 100644 --- a/src/components/chat/hooks/useAgentToolActions.ts +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -5,6 +5,11 @@ import { useCallback } from "react"; import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore"; import type { StreamEvent } from "@/lib/chatStream"; import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types"; +import { + APPLY_LAYER_STYLE_TOOL, + describeApplyLayerStyle, + parseApplyLayerStylePayload, +} from "../toolCallStyleHelpers"; type ToolCallEvent = StreamEvent & { type: "tool_call" }; @@ -248,6 +253,23 @@ const buildToolAction = ( }; } + if (tool === APPLY_LAYER_STYLE_TOOL) { + const payload = parseApplyLayerStylePayload(params); + return { + action: payload + ? { + type: "apply_layer_style", + layerId: payload.layerId, + resetToDefault: payload.resetToDefault, + styleConfig: payload.styleConfig, + } + : null, + kind: "map", + title: payload?.resetToDefault ? "重置图层样式" : "应用图层样式", + description: payload ? describeApplyLayerStyle(payload) : "图层样式", + }; + } + return { action: null, kind: "tool", diff --git a/src/components/chat/toolCallStyleHelpers.ts b/src/components/chat/toolCallStyleHelpers.ts new file mode 100644 index 0000000..578c097 --- /dev/null +++ b/src/components/chat/toolCallStyleHelpers.ts @@ -0,0 +1,150 @@ +import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes"; + +export type ApplyLayerStyleActionPayload = { + layerId: DefaultLayerStyleId; + resetToDefault: boolean; + styleConfig?: Partial<StyleConfig>; +}; + +export const APPLY_LAYER_STYLE_TOOL = "apply_layer_style"; + +const LAYER_LABELS: Record<DefaultLayerStyleId, string> = { + junctions: "节点", + pipes: "管道", +}; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value.trim() ? value.trim() : undefined; + +const asNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) + ? value + : typeof value === "string" && value.trim() && Number.isFinite(Number(value)) + ? Number(value) + : undefined; + +const asBoolean = (value: unknown): boolean | undefined => + typeof value === "boolean" + ? value + : typeof value === "string" + ? value === "true" + ? true + : value === "false" + ? false + : undefined + : undefined; + +const asNumberArray = (value: unknown): number[] | undefined => + Array.isArray(value) + ? value + .map((item) => asNumber(item)) + .filter((item): item is number => item !== undefined) + : undefined; + +const asStringArray = (value: unknown): string[] | undefined => + Array.isArray(value) + ? value + .map((item) => asString(item)) + .filter((item): item is string => item !== undefined) + : undefined; + +export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => { + const normalized = asString(value)?.toLowerCase(); + if (normalized === "junctions" || normalized === "pipes") { + return normalized; + } + return null; +}; + +export const getStyleLayerLabel = (layerId: DefaultLayerStyleId): string => + LAYER_LABELS[layerId]; + +export const parseApplyLayerStylePayload = ( + params: Record<string, unknown>, +): ApplyLayerStyleActionPayload | null => { + const layerId = normalizeStyleLayerId(params.layer_id ?? params.layerId); + if (!layerId) { + return null; + } + + const resetToDefault = Boolean( + asBoolean(params.reset_to_default ?? params.resetToDefault), + ); + const rawStyleConfig = + params.style_config && typeof params.style_config === "object" + ? (params.style_config as Record<string, unknown>) + : params.styleConfig && typeof params.styleConfig === "object" + ? (params.styleConfig as Record<string, unknown>) + : null; + + const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig + ? { + property: asString(rawStyleConfig.property), + classificationMethod: asString( + rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod, + ), + segments: asNumber(rawStyleConfig.segments), + minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize), + maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize), + minStrokeWidth: asNumber( + rawStyleConfig.min_stroke_width ?? rawStyleConfig.minStrokeWidth, + ), + maxStrokeWidth: asNumber( + rawStyleConfig.max_stroke_width ?? rawStyleConfig.maxStrokeWidth, + ), + fixedStrokeWidth: asNumber( + rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth, + ), + colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType), + singlePaletteIndex: asNumber( + rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex, + ), + gradientPaletteIndex: asNumber( + rawStyleConfig.gradient_palette_index ?? rawStyleConfig.gradientPaletteIndex, + ), + rainbowPaletteIndex: asNumber( + rawStyleConfig.rainbow_palette_index ?? rawStyleConfig.rainbowPaletteIndex, + ), + showLabels: asBoolean(rawStyleConfig.show_labels ?? rawStyleConfig.showLabels), + showId: asBoolean(rawStyleConfig.show_id ?? rawStyleConfig.showId), + opacity: asNumber(rawStyleConfig.opacity), + adjustWidthByProperty: asBoolean( + rawStyleConfig.adjust_width_by_property ?? + rawStyleConfig.adjustWidthByProperty, + ), + customBreaks: asNumberArray( + rawStyleConfig.custom_breaks ?? rawStyleConfig.customBreaks, + ), + customColors: asStringArray( + rawStyleConfig.custom_colors ?? rawStyleConfig.customColors, + ), + } + : undefined; + + const hasStyleOverrides = + styleConfig && + Object.values(styleConfig).some((value) => + Array.isArray(value) ? value.length > 0 : value !== undefined, + ); + + if (!resetToDefault && !hasStyleOverrides) { + return null; + } + + return { + layerId, + resetToDefault, + styleConfig: hasStyleOverrides ? styleConfig : undefined, + }; +}; + +export const describeApplyLayerStyle = ( + payload: ApplyLayerStyleActionPayload, +): string => { + const layerLabel = getStyleLayerLabel(payload.layerId); + if (payload.resetToDefault) { + return `${layerLabel} · 重置默认样式`; + } + const property = payload.styleConfig?.property; + return property ? `${layerLabel} · ${property}` : `${layerLabel} · 应用样式`; +}; diff --git a/src/components/olmap/core/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx index 946cd3a..afc1686 100644 --- a/src/components/olmap/core/Controls/StyleEditorPanel.tsx +++ b/src/components/olmap/core/Controls/StyleEditorPanel.tsx @@ -2,34 +2,25 @@ import React from "react"; import StyleEditorForm from "./StyleEditorForm"; import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets"; -import { useStyleEditor } from "./useStyleEditor"; import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes"; const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ - layerStyleStates, - setLayerStyleStates, + isReady, + renderLayers, + selectedRenderLayer, + styleConfig, + setStyleConfig, + availableProperties, + onLayerChange, + onPropertyChange, + onClassificationMethodChange, + onSegmentsChange, + onCustomBreakChange, + onCustomBreakBlur, + onColorTypeChange, + onApply, + onReset, }) => { - const { - isReady, - renderLayers, - selectedRenderLayer, - styleConfig, - setStyleConfig, - availableProperties, - handleLayerChange, - handlePropertyChange, - handleClassificationMethodChange, - handleSegmentsChange, - handleCustomBreakChange, - handleCustomBreakBlur, - handleColorTypeChange, - handleApply, - handleReset, - } = useStyleEditor({ - layerStyleStates, - setLayerStyleStates, - }); - if (!isReady) { return <div>Loading...</div>; } @@ -41,15 +32,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ styleConfig={styleConfig} setStyleConfig={setStyleConfig} availableProperties={availableProperties} - onLayerChange={handleLayerChange} - onPropertyChange={handlePropertyChange} - onClassificationMethodChange={handleClassificationMethodChange} - onSegmentsChange={handleSegmentsChange} - onCustomBreakChange={handleCustomBreakChange} - onCustomBreakBlur={handleCustomBreakBlur} - onColorTypeChange={handleColorTypeChange} - onApply={handleApply} - onReset={handleReset} + onLayerChange={onLayerChange} + onPropertyChange={onPropertyChange} + onClassificationMethodChange={onClassificationMethodChange} + onSegmentsChange={onSegmentsChange} + onCustomBreakChange={onCustomBreakChange} + onCustomBreakBlur={onCustomBreakBlur} + onColorTypeChange={onColorTypeChange} + onApply={onApply} + onReset={onReset} /> ); }; diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 333434e..772123d 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -24,6 +24,7 @@ import { buildFeatureProperties, } from "./toolbarFeatureHelpers"; import { useToolbarChatActions } from "./useToolbarChatActions"; +import { useStyleEditor } from "./useStyleEditor"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; @@ -81,20 +82,27 @@ const Toolbar: React.FC<ToolbarProps> = ({ endTime?: string; } | null>(null); + // 样式状态管理 - 在 Toolbar 中管理,带有默认样式 + const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>( + () => createDefaultLayerStyleStates() + ); + const styleEditor = useStyleEditor({ + layerStyleStates, + setLayerStyleStates, + }); + useToolbarChatActions({ setHighlightFeatures, setChatPanelFeatureInfos, setChatPanelType, setChatPanelTimeRange, setShowHistoryPanel, + setShowStyleEditor, setActiveTools, + applyExternalStyle: styleEditor.applyExternalStyle, + resetExternalStyle: styleEditor.resetExternalStyle, }); - // 样式状态管理 - 在 Toolbar 中管理,带有默认样式 - const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>( - () => createDefaultLayerStyleStates() - ); - // 计算激活的图例配置 const activeLegendConfigs = layerStyleStates .filter((state) => state.isActive && state.legendConfig.property) @@ -444,8 +452,21 @@ const Toolbar: React.FC<ToolbarProps> = ({ {showDrawPanel && map && <DrawPanel />} <div style={{ display: showStyleEditor ? "block" : "none" }}> <StyleEditorPanel - layerStyleStates={layerStyleStates} - setLayerStyleStates={setLayerStyleStates} + isReady={styleEditor.isReady} + renderLayers={styleEditor.renderLayers} + selectedRenderLayer={styleEditor.selectedRenderLayer} + styleConfig={styleEditor.styleConfig} + setStyleConfig={styleEditor.setStyleConfig} + availableProperties={styleEditor.availableProperties} + onLayerChange={styleEditor.handleLayerChange} + onPropertyChange={styleEditor.handlePropertyChange} + onClassificationMethodChange={styleEditor.handleClassificationMethodChange} + onSegmentsChange={styleEditor.handleSegmentsChange} + onCustomBreakChange={styleEditor.handleCustomBreakChange} + onCustomBreakBlur={styleEditor.handleCustomBreakBlur} + onColorTypeChange={styleEditor.handleColorTypeChange} + onApply={styleEditor.handleApply} + onReset={styleEditor.handleReset} /> </div> <ToolbarHistoryPanel diff --git a/src/components/olmap/core/Controls/styleEditorTypes.ts b/src/components/olmap/core/Controls/styleEditorTypes.ts index 034bce7..a5eb539 100644 --- a/src/components/olmap/core/Controls/styleEditorTypes.ts +++ b/src/components/olmap/core/Controls/styleEditorTypes.ts @@ -34,7 +34,7 @@ export interface LayerStyleState { export type DefaultLayerStyleId = "junctions" | "pipes"; -export interface StyleEditorPanelProps { +export interface StyleEditorStateProps { layerStyleStates: LayerStyleState[]; setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>; } @@ -60,3 +60,7 @@ export interface StyleEditorFormProps { onApply: () => void; onReset: () => void; } + +export interface StyleEditorPanelProps extends StyleEditorFormProps { + isReady: boolean; +} diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index 1da8a3f..d317cd3 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -25,8 +25,10 @@ import { } from "./styleEditorUtils"; import { AvailableProperty, + DefaultLayerStyleId, LayerStyleState, - StyleEditorPanelProps, + StyleConfig, + StyleEditorStateProps, } from "./styleEditorTypes"; import { LegendStyleConfig } from "./StyleLegend"; import { calculateClassification } from "@utils/breaks_classification"; @@ -36,7 +38,7 @@ const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5]; export const useStyleEditor = ({ layerStyleStates, setLayerStyleStates, -}: StyleEditorPanelProps) => { +}: StyleEditorStateProps) => { const map = useMap(); const data = useData(); const { open } = useNotification(); @@ -85,6 +87,51 @@ export const useStyleEditor = ({ latestLayerStyleStatesRef.current = layerStyleStates; }, [layerStyleStates]); + const upsertLayerStyleState = useCallback( + (newStyleState: LayerStyleState) => { + const existingState = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === newStyleState.layerId + ); + if ( + existingState && + JSON.stringify(existingState.styleConfig) === + JSON.stringify(newStyleState.styleConfig) && + JSON.stringify(existingState.legendConfig) === + JSON.stringify(newStyleState.legendConfig) && + existingState.layerName === newStyleState.layerName && + existingState.isActive === newStyleState.isActive + ) { + return; + } + + setLayerStyleStates((prev) => { + const existingIndex = prev.findIndex( + (state) => state.layerId === newStyleState.layerId + ); + const nextStates = + existingIndex === -1 + ? [...prev, newStyleState] + : prev.map((state, index) => + index === existingIndex ? newStyleState : state + ); + latestLayerStyleStatesRef.current = nextStates; + return nextStates; + }); + }, + [setLayerStyleStates] + ); + + const removeLayerStyleState = useCallback( + (layerId: string) => { + setLayerStyleStates((prev) => { + const nextStates = prev.filter((state) => state.layerId !== layerId); + latestLayerStyleStatesRef.current = nextStates; + return nextStates; + }); + }, + [setLayerStyleStates] + ); + const getRenderLayersById = useCallback( (layerId: string) => activeMaps.flatMap((targetMap) => @@ -191,28 +238,9 @@ export const useStyleEditor = ({ isActive: true, }; - setLayerStyleStates((prev) => { - const existingIndex = prev.findIndex((state) => state.layerId === layerId); - if (existingIndex !== -1) { - const existingState = prev[existingIndex]; - if ( - JSON.stringify(existingState.styleConfig) === - JSON.stringify(newStyleState.styleConfig) && - JSON.stringify(existingState.legendConfig) === - JSON.stringify(newStyleState.legendConfig) && - existingState.layerName === newStyleState.layerName && - existingState.isActive === newStyleState.isActive - ) { - return prev; - } - const updated = [...prev]; - updated[existingIndex] = newStyleState; - return updated; - } - return [...prev, newStyleState]; - }); + upsertLayerStyleState(newStyleState); }, - [availableProperties, selectedRenderLayer, setLayerStyleStates, styleConfig] + [availableProperties, selectedRenderLayer, styleConfig, upsertLayerStyleState] ); const applyContourLayerStyle = useCallback( @@ -520,9 +548,7 @@ export const useStyleEditor = ({ setShowJunctionTextLayer?.(styleConfig.showLabels); setShowJunctionId?.(styleConfig.showId); setApplyJunctionStyle(true); - if (property === "pressure") { - setContourLayerAvailable?.(true); - } + setContourLayerAvailable?.(property === "pressure"); saveLayerStyle(layerId); open?.({ type: "success", @@ -571,7 +597,7 @@ export const useStyleEditor = ({ targetLayer.setStyle(defaultFlatStyle); }); - setLayerStyleStates((prev) => prev.filter((state) => state.layerId !== layerId)); + removeLayerStyleState(layerId); if (layerId === "junctions") { setApplyJunctionStyle(false); @@ -593,7 +619,7 @@ export const useStyleEditor = ({ setContourLayerAvailable, setContours, setJunctionText, - setLayerStyleStates, + removeLayerStyleState, setPipeText, setShowJunctionId, setShowJunctionTextLayer, @@ -602,6 +628,211 @@ export const useStyleEditor = ({ setWaterflowLayerAvailable, ]); + const normalizeExternalStyleConfig = useCallback( + (layerId: DefaultLayerStyleId, overrides?: Partial<StyleConfig>): StyleConfig => { + const currentStyleState = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === layerId + ); + const baseStyleConfig = + currentStyleState?.styleConfig || createDefaultLayerStyleState(layerId).styleConfig; + const nextStyleConfig: StyleConfig = { + ...baseStyleConfig, + ...overrides, + customBreaks: overrides?.customBreaks + ? [...overrides.customBreaks] + : [...(baseStyleConfig.customBreaks || [])], + customColors: overrides?.customColors + ? [...overrides.customColors] + : [...(baseStyleConfig.customColors || [])], + }; + + nextStyleConfig.segments = Math.max(1, Math.round(nextStyleConfig.segments || 1)); + nextStyleConfig.opacity = Math.min(1, Math.max(0, nextStyleConfig.opacity)); + nextStyleConfig.singlePaletteIndex = Math.max( + 0, + Math.round(nextStyleConfig.singlePaletteIndex || 0) + ); + nextStyleConfig.gradientPaletteIndex = Math.max( + 0, + Math.round(nextStyleConfig.gradientPaletteIndex || 0) + ); + nextStyleConfig.rainbowPaletteIndex = Math.max( + 0, + Math.round(nextStyleConfig.rainbowPaletteIndex || 0) + ); + nextStyleConfig.minSize = Math.max(1, nextStyleConfig.minSize); + nextStyleConfig.maxSize = Math.max(nextStyleConfig.minSize, nextStyleConfig.maxSize); + nextStyleConfig.minStrokeWidth = Math.max(1, nextStyleConfig.minStrokeWidth); + nextStyleConfig.maxStrokeWidth = Math.max( + nextStyleConfig.minStrokeWidth, + nextStyleConfig.maxStrokeWidth + ); + nextStyleConfig.fixedStrokeWidth = Math.max(1, nextStyleConfig.fixedStrokeWidth); + nextStyleConfig.customColors = + nextStyleConfig.colorType === "custom" + ? getDefaultCustomColors( + nextStyleConfig.segments, + nextStyleConfig.customColors || [] + ) + : nextStyleConfig.customColors; + nextStyleConfig.customBreaks = + nextStyleConfig.classificationMethod === "custom_breaks" + ? normalizeCustomBreaks( + nextStyleConfig.customBreaks || + getBreakDefaults( + nextStyleConfig.segments, + nextStyleConfig.property, + getRenderLayersById(layerId)[0] + ), + nextStyleConfig.segments + ) + : nextStyleConfig.customBreaks; + + return nextStyleConfig; + }, + [getBreakDefaults, getRenderLayersById] + ); + + const applyExternalStyle = useCallback( + (layerId: DefaultLayerStyleId, overrides?: Partial<StyleConfig>) => { + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) { + open?.({ + type: "error", + message: `未找到${layerId === "junctions" ? "节点" : "管道"}图层,无法应用样式。`, + }); + return; + } + + const nextStyleConfig = normalizeExternalStyleConfig(layerId, overrides); + if (!nextStyleConfig.property) { + open?.({ + type: "error", + message: "样式工具缺少有效的渲染属性,无法应用样式。", + }); + return; + } + + const layerName = targetLayer.get("name") || (layerId === "junctions" ? "节点" : "管道"); + const targetProperties = (targetLayer.get("properties") || []) as AvailableProperty[]; + const propertyLabel = + targetProperties.find((item) => item.value === nextStyleConfig.property)?.name || + nextStyleConfig.property; + + setSelectedRenderLayer(targetLayer); + setStyleConfig(nextStyleConfig); + upsertLayerStyleState({ + layerId, + layerName, + styleConfig: nextStyleConfig, + legendConfig: { + layerId, + layerName, + property: propertyLabel, + colors: [], + type: targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"), + dimensions: [], + breaks: [], + }, + isActive: true, + }); + + if (layerId === "junctions") { + setJunctionText?.(nextStyleConfig.property); + setShowJunctionTextLayer?.(nextStyleConfig.showLabels); + setShowJunctionId?.(nextStyleConfig.showId); + setContourLayerAvailable?.(nextStyleConfig.property === "pressure"); + setApplyJunctionStyle(true); + } else { + setPipeText?.(nextStyleConfig.property); + setShowPipeTextLayer?.(nextStyleConfig.showLabels); + setShowPipeId?.(nextStyleConfig.showId); + setWaterflowLayerAvailable?.(true); + setApplyPipeStyle(true); + } + + applyClassificationStyle(layerId, nextStyleConfig); + open?.({ + type: "success", + message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已应用。`, + }); + }, + [ + applyClassificationStyle, + getRenderLayersById, + normalizeExternalStyleConfig, + open, + setContourLayerAvailable, + setJunctionText, + setPipeText, + setShowJunctionId, + setShowJunctionTextLayer, + setShowPipeId, + setShowPipeTextLayer, + setWaterflowLayerAvailable, + upsertLayerStyleState, + ] + ); + + const resetExternalStyle = useCallback( + (layerId: DefaultLayerStyleId) => { + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) { + open?.({ + type: "error", + message: `未找到${layerId === "junctions" ? "节点" : "管道"}图层,无法重置样式。`, + }); + return; + } + + const defaultStyleConfig = createDefaultLayerStyleState(layerId).styleConfig; + const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; + + setSelectedRenderLayer(targetLayer); + setStyleConfig(defaultStyleConfig); + + getRenderLayersById(layerId).forEach((renderLayer) => { + renderLayer.setStyle(defaultFlatStyle); + }); + + removeLayerStyleState(layerId); + + if (layerId === "junctions") { + setApplyJunctionStyle(false); + setShowJunctionTextLayer?.(false); + setShowJunctionId?.(false); + setJunctionText?.(""); + setContours?.([]); + setContourLayerAvailable?.(false); + } else { + setApplyPipeStyle(false); + setShowPipeTextLayer?.(false); + setShowPipeId?.(false); + setPipeText?.(""); + setWaterflowLayerAvailable?.(false); + } + + open?.({ + type: "success", + message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已重置。`, + }); + }, + [ + getRenderLayersById, + open, + removeLayerStyleState, + setContourLayerAvailable, + setContours, + setJunctionText, + setPipeText, + setShowJunctionId, + setShowJunctionTextLayer, + setShowPipeId, + setShowPipeTextLayer, + setWaterflowLayerAvailable, + ] + ); + const handleLayerChange = useCallback( (index: number) => { const newLayer = index >= 0 ? renderLayers[index] : undefined; @@ -747,6 +978,7 @@ export const useStyleEditor = ({ nextStates[index] = defaultState; } }); + latestLayerStyleStatesRef.current = nextStates; return nextStates; }); @@ -940,5 +1172,7 @@ export const useStyleEditor = ({ handleColorTypeChange, handleApply, handleReset, + applyExternalStyle, + resetExternalStyle, }; }; diff --git a/src/components/olmap/core/Controls/useToolbarChatActions.ts b/src/components/olmap/core/Controls/useToolbarChatActions.ts index bf9f3a9..f29a982 100644 --- a/src/components/olmap/core/Controls/useToolbarChatActions.ts +++ b/src/components/olmap/core/Controls/useToolbarChatActions.ts @@ -13,6 +13,7 @@ import { apiFetch } from "@/lib/apiFetch"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { config } from "@/config/config"; import { useMap } from "../MapComponent"; +import type { DefaultLayerStyleId, StyleConfig } from "./styleEditorTypes"; type UseToolbarChatActionsParams = { setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>; @@ -22,7 +23,13 @@ type UseToolbarChatActionsParams = { SetStateAction<{ startTime?: string; endTime?: string } | null> >; setShowHistoryPanel: Dispatch<SetStateAction<boolean>>; + setShowStyleEditor: Dispatch<SetStateAction<boolean>>; setActiveTools: Dispatch<SetStateAction<string[]>>; + applyExternalStyle: ( + layerId: DefaultLayerStyleId, + styleConfig?: Partial<StyleConfig> + ) => void; + resetExternalStyle: (layerId: DefaultLayerStyleId) => void; }; export const useToolbarChatActions = ({ @@ -31,7 +38,10 @@ export const useToolbarChatActions = ({ setChatPanelType, setChatPanelTimeRange, setShowHistoryPanel, + setShowStyleEditor, setActiveTools, + applyExternalStyle, + resetExternalStyle, }: UseToolbarChatActionsParams) => { const map = useMap(); const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null); @@ -206,17 +216,30 @@ export const useToolbarChatActions = ({ })(); break; } + case "apply_layer_style": { + setShowStyleEditor(true); + setActiveTools((prev) => (prev.includes("style") ? prev : [...prev, "style"])); + if (action.resetToDefault) { + resetExternalStyle(action.layerId); + } else { + applyExternalStyle(action.layerId, action.styleConfig); + } + break; + } } }, [ + applyExternalStyle, disposeChatJunctionRender, map, + resetExternalStyle, setActiveTools, setChatPanelFeatureInfos, setChatPanelTimeRange, setChatPanelType, setHighlightFeatures, setShowHistoryPanel, + setShowStyleEditor, ], ), ); diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index 317ffa4..7a0e4d4 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -1,5 +1,7 @@ import { create } from "zustand"; +import type { DefaultLayerStyleId, StyleConfig } from "@components/olmap/core/Controls/styleEditorTypes"; + /* ------------------------------------------------------------------ */ /* Chat Tool Action Store */ /* Decouples chat tool calls from map/panel execution. */ @@ -39,6 +41,12 @@ export type ChatToolAction = type: "render_junctions"; renderRef: string; sessionId?: string; + } + | { + type: "apply_layer_style"; + layerId: DefaultLayerStyleId; + resetToDefault: boolean; + styleConfig?: Partial<StyleConfig>; }; interface ChatToolState { -- 2.54.0 From 888132a60f1b0d65d64673a752293aa5b3962302 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 3 Jun 2026 11:17:27 +0800 Subject: [PATCH 153/281] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/olmap/core/Controls/Timeline.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index 41b1c3f..dcf48de 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -623,7 +623,10 @@ const Timeline: React.FC<TimelineProps> = ({ // 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变 const calculationDate = selectedDate; const calculationTime = currentTime; - const calculationDateStr = calculationDate.toISOString().split("T")[0]; + const calculationDateTime = currentTimeToDate( + calculationDate, + calculationTime + ); setIsCalculating(true); // 显示处理中的通知 @@ -635,8 +638,7 @@ const Timeline: React.FC<TimelineProps> = ({ try { const body = { name: NETWORK_NAME, - simulation_date: calculationDateStr, // YYYY-MM-DD - start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00 + start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"), duration: calculatedInterval, }; @@ -651,7 +653,9 @@ const Timeline: React.FC<TimelineProps> = ({ }, ); - if (response.ok) { + const result = await response.json().catch(() => null); + + if (response.ok && result?.status === "success") { open?.({ type: "success", message: "重新计算成功", @@ -660,9 +664,11 @@ const Timeline: React.FC<TimelineProps> = ({ clearCacheAndRefetch(calculationDate, calculationTime); setForceStyleAutoApplyVersion?.((prev) => prev + 1); } else { + const errorMessage = + result?.detail || result?.message || "重新计算失败"; open?.({ type: "error", - message: "重新计算失败", + message: errorMessage, }); } } catch (error) { -- 2.54.0 From fa3e6b6e84a0f2eb2bcf813a8b12a61c801c26e3 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 3 Jun 2026 15:01:24 +0800 Subject: [PATCH 154/281] =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=89=A5=E7=A6=BB=EF=BC=8C=E9=81=BF=E5=85=8D=E5=8F=97?= =?UTF-8?q?=E9=95=BF=E4=BF=A1=E6=81=AF=E5=88=97=E8=A1=A8=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=EF=BC=9B=E8=A6=86=E5=86=99=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=9D=A1=E7=8A=B6=E6=80=81=E5=8A=A8=E4=BD=9C=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=BC=BA=E5=88=B6=E6=8B=89=E5=88=B0=E6=9C=80=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentComposer.tsx | 58 ++++++++++++++------ src/components/chat/AgentWorkspace.tsx | 6 +++ src/components/chat/GlobalChatbox.tsx | 73 +++++++++++++++----------- 3 files changed, 90 insertions(+), 47 deletions(-) diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index 4dd3fbf..f9cad22 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -28,44 +28,65 @@ import BoltRounded from "@mui/icons-material/BoltRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import type { AgentModel } from "@/lib/chatStream"; +export type AgentComposerHandle = { + focus: () => void; + clear: () => void; + append: (text: string) => void; + setValue: (value: string) => void; + getValue: () => string; +}; + type AgentComposerProps = { - input: string; - inputRef: React.RefObject<HTMLInputElement | null>; isHydrating?: boolean; isStreaming: boolean; isListening: boolean; isSttSupported: boolean; presets: string[]; - onInputChange: (value: string) => void; - onSend: () => void; + onSend: (prompt: string) => void; onAbort: () => void; onStartListening: () => void; onStopListening: () => void; - onPresetSelect: (prompt: string) => void; selectedModel: AgentModel; onModelChange: (model: AgentModel) => void; }; -export const AgentComposer = ({ - input, - inputRef, +export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ isHydrating = false, isStreaming, isListening, isSttSupported, presets, - onInputChange, onSend, onAbort, onStartListening, onStopListening, - onPresetSelect, selectedModel, onModelChange, -}: AgentComposerProps) => { +}, ref) { const theme = useTheme(); - const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; + const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); + const [input, setInput] = React.useState(""); const [isPresetOpen, setIsPresetOpen] = React.useState(false); + const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; + + React.useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + clear: () => setInput(""), + append: (text: string) => setInput((prev) => prev + text), + setValue: (value: string) => setInput(value), + getValue: () => input, + }), + [input], + ); + + const handleSend = React.useCallback(() => { + const prompt = input.trim(); + if (!prompt || isStreaming || isHydrating) return; + setInput(""); + onSend(prompt); + }, [input, isHydrating, isStreaming, onSend]); return ( <Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}> @@ -121,8 +142,11 @@ export const AgentComposer = ({ size="medium" clickable onClick={() => { - onPresetSelect(prompt); + setInput(prompt); setIsPresetOpen(false); + window.setTimeout(() => { + inputRef.current?.focus(); + }, 0); }} sx={{ height: 32, @@ -165,11 +189,11 @@ export const AgentComposer = ({ <TextField inputRef={inputRef} value={input} - onChange={(event) => onInputChange(event.target.value)} + onChange={(event) => setInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); - onSend(); + handleSend(); } }} placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} @@ -362,7 +386,7 @@ export const AgentComposer = ({ <motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> <IconButton disabled={!canSend} - onClick={onSend} + onClick={handleSend} aria-label="发送" size="small" sx={{ @@ -397,4 +421,4 @@ export const AgentComposer = ({ </Box> </Box> ); -}; +}); diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 06eafb9..c85b387 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -23,6 +23,8 @@ type AgentWorkspaceProps = { branchGroups: BranchGroup[]; branchTransition: BranchTransition | null; isStreaming: boolean; + scrollContainerRef: React.RefObject<HTMLDivElement | null>; + onScroll: React.UIEventHandler<HTMLDivElement>; bottomRef: React.RefObject<HTMLDivElement | null>; speakingMessageId: string | null; speechState: SpeechState; @@ -155,6 +157,8 @@ export const AgentWorkspace = ({ branchGroups, branchTransition, isStreaming, + scrollContainerRef, + onScroll, bottomRef, speakingMessageId, speechState, @@ -216,6 +220,8 @@ export const AgentWorkspace = ({ return ( <Box + ref={scrollContainerRef} + onScroll={onScroll} sx={{ flex: 1, overflowY: "auto", diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 5ecb3e3..b67b37c 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material"; import type { AgentModel } from "@/lib/chatStream"; import { useProjectStore } from "@/store/projectStore"; -import { AgentComposer } from "./AgentComposer"; +import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentWorkspace } from "./AgentWorkspace"; @@ -22,7 +22,6 @@ import { useAgentChatSession } from "./hooks/useAgentChatSession"; import { useAgentToolActions } from "./hooks/useAgentToolActions"; export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { - const [input, setInput] = useState(""); const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); @@ -31,8 +30,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { ); const bottomRef = useRef<HTMLDivElement>(null); - const inputRef = useRef<HTMLInputElement | null>(null); + const workspaceRef = useRef<HTMLDivElement | null>(null); + const composerRef = useRef<AgentComposerHandle | null>(null); const hasResetForOpenRef = useRef(false); + const shouldAutoScrollRef = useRef(true); const theme = useTheme(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -47,7 +48,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { } = useSpeechSynthesis(); const handleSpeechResult = useCallback((text: string) => { - setInput((prev) => prev + text); + composerRef.current?.append(text); }, []); const { @@ -83,9 +84,22 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { getModel: () => selectedModel, }); + const syncAutoScrollState = useCallback(() => { + const container = workspaceRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + shouldAutoScrollRef.current = distanceFromBottom <= 120; + }, []); + + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + bottomRef.current?.scrollIntoView({ behavior }); + }, []); + useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, isStreaming]); + if (!shouldAutoScrollRef.current) return; + scrollToBottom(isStreaming ? "auto" : "smooth"); + }, [isStreaming, messages, scrollToBottom]); useEffect(() => { if (!open) { @@ -96,38 +110,33 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { hasResetForOpenRef.current = true; const timer = window.setTimeout(() => { + shouldAutoScrollRef.current = true; createSession(); - setInput(""); + composerRef.current?.clear(); setIsHistoryOpen(false); - inputRef.current?.focus(); - bottomRef.current?.scrollIntoView({ behavior: "auto" }); + composerRef.current?.focus(); + scrollToBottom("auto"); }, 0); return () => window.clearTimeout(timer); - }, [createSession, isHydrating, open]); + }, [createSession, isHydrating, open, scrollToBottom]); - const handleSend = useCallback(() => { - const prompt = input.trim(); - if (!prompt || isStreaming) return; - setInput(""); + const handleSend = useCallback((prompt: string) => { + if (isStreaming) return; + shouldAutoScrollRef.current = true; void sendPrompt(prompt); - }, [input, isStreaming, sendPrompt]); - - const handlePresetPromptSelect = useCallback((prompt: string) => { - setInput(prompt); - window.setTimeout(() => { - inputRef.current?.focus(); - }, 0); - }, []); + }, [isStreaming, sendPrompt]); const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); + shouldAutoScrollRef.current = true; createSession(); - setInput(""); + composerRef.current?.clear(); window.setTimeout(() => { - inputRef.current?.focus(); + composerRef.current?.focus(); + scrollToBottom("auto"); }, 0); - }, [createSession, handleStopSpeech, stopListening]); + }, [createSession, handleStopSpeech, scrollToBottom, stopListening]); const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); @@ -135,12 +144,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleSelectSession = useCallback( (storageSessionId: string) => { - setInput(""); + shouldAutoScrollRef.current = true; + composerRef.current?.clear(); void switchSession(storageSessionId); }, [switchSession], ); + const handleWorkspaceScroll = useCallback(() => { + syncAutoScrollState(); + }, [syncAutoScrollState]); + const handleDeleteSession = useCallback( (storageSessionId: string) => { void removeSession(storageSessionId); @@ -320,6 +334,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { branchGroups={branchGroups} branchTransition={branchTransition} isStreaming={isStreaming} + scrollContainerRef={workspaceRef} + onScroll={handleWorkspaceScroll} bottomRef={bottomRef} speakingMessageId={speakingMessageId} speechState={speechState} @@ -334,19 +350,16 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { /> <AgentComposer - input={input} - inputRef={inputRef} + ref={composerRef} isHydrating={isHydrating} isStreaming={isStreaming} isListening={isListening} isSttSupported={isSttSupported} presets={PRESET_PROMPTS} - onInputChange={setInput} onSend={handleSend} onAbort={abort} onStartListening={startListening} onStopListening={stopListening} - onPresetSelect={handlePresetPromptSelect} selectedModel={selectedModel} onModelChange={setSelectedModel} /> -- 2.54.0 From 06a3f32d2d7e9df97d7dfd4c9f677e21aeca338e Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 3 Jun 2026 16:58:10 +0800 Subject: [PATCH 155/281] =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD=E5=B9=B6=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=EF=BC=9B=E6=92=A4=E9=94=80=E6=BB=9A=E5=8A=A8=E6=9D=A1=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentProgressTimeline.tsx | 16 ++++++++++++- src/components/chat/AgentTurn.tsx | 24 ++++++++++++------- src/components/chat/AgentWorkspace.tsx | 6 ----- src/components/chat/GlobalChatbox.tsx | 21 ---------------- 4 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index cecf6fd..83c99e6 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -85,7 +85,12 @@ const formatToolTitle = (item: ChatProgress) => { return item.title; }; -export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => { +type AgentProgressTimelineProps = { + progress: ChatProgress[]; + isAborted?: boolean; +}; + +const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => { const theme = useTheme(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -356,3 +361,12 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP </Box> ); }; + +export const AgentProgressTimeline = React.memo( + AgentProgressTimelineInner, + (prevProps, nextProps) => + prevProps.progress === nextProps.progress && + prevProps.isAborted === nextProps.isAborted, +); + +AgentProgressTimeline.displayName = "AgentProgressTimeline"; diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 8083954..03de6ce 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import React from "react"; +import React, { useMemo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { AnimatePresence, motion } from "framer-motion"; @@ -85,15 +85,21 @@ export const AgentTurn = React.memo( const [editDraft, setEditDraft] = React.useState(message.content); const rootMessageId = message.branchRootId ?? message.id; - const parsedAssistantSections = - !isUser && !isErrorMessage - ? parseAssistantMessageSections(message.content) - : null; + const parsedAssistantSections = useMemo( + () => + !isUser && !isErrorMessage + ? parseAssistantMessageSections(message.content) + : null, + [isErrorMessage, isUser, message.content], + ); const answerContent = parsedAssistantSections?.answer ?? message.content; - const contentSegments: ContentSegment[] = - !isUser && !isErrorMessage - ? parseContentWithToolCalls(answerContent).segments - : [{ type: "text", content: answerContent }]; + const contentSegments: ContentSegment[] = useMemo( + () => + !isUser && !isErrorMessage + ? parseContentWithToolCalls(answerContent).segments + : [{ type: "text", content: answerContent }], + [answerContent, isErrorMessage, isUser], + ); if (isUser) { return ( diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index c85b387..06eafb9 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -23,8 +23,6 @@ type AgentWorkspaceProps = { branchGroups: BranchGroup[]; branchTransition: BranchTransition | null; isStreaming: boolean; - scrollContainerRef: React.RefObject<HTMLDivElement | null>; - onScroll: React.UIEventHandler<HTMLDivElement>; bottomRef: React.RefObject<HTMLDivElement | null>; speakingMessageId: string | null; speechState: SpeechState; @@ -157,8 +155,6 @@ export const AgentWorkspace = ({ branchGroups, branchTransition, isStreaming, - scrollContainerRef, - onScroll, bottomRef, speakingMessageId, speechState, @@ -220,8 +216,6 @@ export const AgentWorkspace = ({ return ( <Box - ref={scrollContainerRef} - onScroll={onScroll} sx={{ flex: 1, overflowY: "auto", diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index b67b37c..a199342 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -30,10 +30,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { ); const bottomRef = useRef<HTMLDivElement>(null); - const workspaceRef = useRef<HTMLDivElement | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null); const hasResetForOpenRef = useRef(false); - const shouldAutoScrollRef = useRef(true); const theme = useTheme(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -84,20 +82,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { getModel: () => selectedModel, }); - const syncAutoScrollState = useCallback(() => { - const container = workspaceRef.current; - if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - shouldAutoScrollRef.current = distanceFromBottom <= 120; - }, []); - const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { bottomRef.current?.scrollIntoView({ behavior }); }, []); useEffect(() => { - if (!shouldAutoScrollRef.current) return; scrollToBottom(isStreaming ? "auto" : "smooth"); }, [isStreaming, messages, scrollToBottom]); @@ -110,7 +99,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { hasResetForOpenRef.current = true; const timer = window.setTimeout(() => { - shouldAutoScrollRef.current = true; createSession(); composerRef.current?.clear(); setIsHistoryOpen(false); @@ -122,14 +110,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleSend = useCallback((prompt: string) => { if (isStreaming) return; - shouldAutoScrollRef.current = true; void sendPrompt(prompt); }, [isStreaming, sendPrompt]); const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); - shouldAutoScrollRef.current = true; createSession(); composerRef.current?.clear(); window.setTimeout(() => { @@ -144,17 +130,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const handleSelectSession = useCallback( (storageSessionId: string) => { - shouldAutoScrollRef.current = true; composerRef.current?.clear(); void switchSession(storageSessionId); }, [switchSession], ); - const handleWorkspaceScroll = useCallback(() => { - syncAutoScrollState(); - }, [syncAutoScrollState]); - const handleDeleteSession = useCallback( (storageSessionId: string) => { void removeSession(storageSessionId); @@ -334,8 +315,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { branchGroups={branchGroups} branchTransition={branchTransition} isStreaming={isStreaming} - scrollContainerRef={workspaceRef} - onScroll={handleWorkspaceScroll} bottomRef={bottomRef} speakingMessageId={speakingMessageId} speechState={speechState} -- 2.54.0 From 20ca410e0ac4019139fda9b5f4d0e7e40cde3030 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 3 Jun 2026 17:49:39 +0800 Subject: [PATCH 156/281] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20TurnList=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentWorkspace.test.tsx | 97 +++++++++++ src/components/chat/AgentWorkspace.tsx | 175 ++++++++++++++++---- 2 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 src/components/chat/AgentWorkspace.test.tsx diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx new file mode 100644 index 0000000..04b4c4f --- /dev/null +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -0,0 +1,97 @@ +/* eslint-disable @next/next/no-img-element */ +import "@testing-library/jest-dom"; +import React from "react"; +import { render } from "@testing-library/react"; + +import { AgentWorkspace } from "./AgentWorkspace"; +import type { Message } from "./GlobalChatbox.types"; + +const renderCounts = new Map<string, number>(); + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt ?? ""} />, +})); + +jest.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, + motion: { + div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>, + }, +})); + +jest.mock("./GlobalChatbox.parts", () => ({ + TypingIndicator: () => <div>typing</div>, +})); + +jest.mock("./AgentTurn", () => ({ + AgentTurn: ({ message }: { message: Message }) => { + renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1); + return <div data-testid={`turn-${message.id}`}>{message.content}</div>; + }, +})); + +describe("AgentWorkspace", () => { + const defaultProps = { + branchGroups: [], + branchTransition: null, + bottomRef: { current: null }, + speakingMessageId: null, + speechState: "idle" as const, + onSpeak: jest.fn(), + onPauseSpeech: jest.fn(), + onResumeSpeech: jest.fn(), + onStopSpeech: jest.fn(), + isTtsSupported: false, + onRegenerate: jest.fn(), + onEditResubmit: jest.fn(), + onCycleBranch: jest.fn(), + }; + + beforeEach(() => { + renderCounts.clear(); + }); + + it("keeps stable history turns from re-rendering while the last assistant message streams", () => { + const userMessage: Message = { + id: "user-1", + role: "user", + content: "question", + }; + const assistantHistoryMessage: Message = { + id: "assistant-1", + role: "assistant", + content: "stable answer", + }; + const streamingMessage: Message = { + id: "assistant-2", + role: "assistant", + content: "partial", + }; + + const { rerender } = render( + <AgentWorkspace + {...defaultProps} + isStreaming + messages={[userMessage, assistantHistoryMessage, streamingMessage]} + />, + ); + + const updatedStreamingMessage: Message = { + ...streamingMessage, + content: "partial with more tokens", + }; + + rerender( + <AgentWorkspace + {...defaultProps} + isStreaming + messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]} + />, + ); + + expect(renderCounts.get("user-1")).toBe(1); + expect(renderCounts.get("assistant-1")).toBe(1); + expect(renderCounts.get("assistant-2")).toBe(2); + }); +}); diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 06eafb9..5f3e03f 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -13,6 +13,7 @@ import { AgentTurn } from "./AgentTurn"; import { TypingIndicator } from "./GlobalChatbox.parts"; import type { BranchGroup, + BranchState, BranchTransition, Message, SpeechState, @@ -36,6 +37,96 @@ type AgentWorkspaceProps = { onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; }; +type TurnListProps = { + messages: Message[]; + branchGroups: BranchGroup[]; + speakingMessageId: string | null; + speechState: SpeechState; + onSpeak: (messageId: string, text: string) => void; + onPauseSpeech: () => void; + onResumeSpeech: () => void; + onStopSpeech: () => void; + isTtsSupported: boolean; + onRegenerate: () => void; + onEditResubmit: (messageId: string, newContent: string) => void; + onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; +}; + +const sameMessages = (left: Message[], right: Message[]) => + left.length === right.length && + left.every((message, index) => message === right[index]); + +const TurnListInner = ({ + messages, + branchGroups, + speakingMessageId, + speechState, + onSpeak, + onPauseSpeech, + onResumeSpeech, + onStopSpeech, + isTtsSupported, + onRegenerate, + onEditResubmit, + onCycleBranch, +}: TurnListProps) => { + const branchStateByRootId = React.useMemo(() => { + const next = new Map<string, BranchState>(); + branchGroups.forEach((group) => { + if (group.branches.length > 1) { + next.set(group.rootMessageId, { + activeIndex: group.activeIndex, + total: group.branches.length, + }); + } + }); + return next; + }, [branchGroups]); + + return ( + <> + {messages.map((message) => { + const rootMessageId = message.branchRootId ?? message.id; + return ( + <AgentTurn + key={rootMessageId} + message={message} + branchState={branchStateByRootId.get(rootMessageId)} + messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} + onSpeak={onSpeak} + onPause={onPauseSpeech} + onResume={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={onRegenerate} + onEditResubmit={onEditResubmit} + onCycleBranch={onCycleBranch} + /> + ); + })} + </> + ); +}; + +const TurnList = React.memo( + TurnListInner, + (prevProps, nextProps) => + sameMessages(prevProps.messages, nextProps.messages) && + prevProps.branchGroups === nextProps.branchGroups && + prevProps.speakingMessageId === nextProps.speakingMessageId && + prevProps.speechState === nextProps.speechState && + prevProps.onSpeak === nextProps.onSpeak && + prevProps.onPauseSpeech === nextProps.onPauseSpeech && + prevProps.onResumeSpeech === nextProps.onResumeSpeech && + prevProps.onStopSpeech === nextProps.onStopSpeech && + prevProps.isTtsSupported === nextProps.isTtsSupported && + prevProps.onRegenerate === nextProps.onRegenerate && + prevProps.onEditResubmit === nextProps.onEditResubmit && + prevProps.onCycleBranch === nextProps.onCycleBranch, +); + +TurnList.displayName = "TurnList"; + const EmptyState = () => { const theme = useTheme(); const capabilities = [ @@ -182,37 +273,12 @@ export const AgentWorkspace = ({ const transitionMessages = branchTransition ? messages.slice(branchTransition.parentCount) : []; - - const renderTurn = (message: Message) => { - const rootMessageId = message.branchRootId ?? message.id; - const branchGroup = branchGroups.find( - (group) => group.rootMessageId === rootMessageId, - ); - - return ( - <AgentTurn - key={rootMessageId} - message={message} - branchState={ - branchGroup && branchGroup.branches.length > 1 - ? { - activeIndex: branchGroup.activeIndex, - total: branchGroup.branches.length, - } - : undefined - } - messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} - onSpeak={onSpeak} - onPause={onPauseSpeech} - onResume={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - onRegenerate={onRegenerate} - onEditResubmit={onEditResubmit} - onCycleBranch={onCycleBranch} - /> - ); - }; + const streamingMessage = + !branchTransition && isStreaming && messages.at(-1)?.role === "assistant" + ? messages.at(-1) + : undefined; + const historyMessages = + streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages; return ( <Box @@ -232,7 +298,37 @@ export const AgentWorkspace = ({ {messages.length > 0 ? ( <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> - {stableMessages.map(renderTurn)} + <TurnList + messages={historyMessages} + branchGroups={branchGroups} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={onSpeak} + onPauseSpeech={onPauseSpeech} + onResumeSpeech={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={onRegenerate} + onEditResubmit={onEditResubmit} + onCycleBranch={onCycleBranch} + /> + + {streamingMessage ? ( + <TurnList + messages={[streamingMessage]} + branchGroups={branchGroups} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={onSpeak} + onPauseSpeech={onPauseSpeech} + onResumeSpeech={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={onRegenerate} + onEditResubmit={onEditResubmit} + onCycleBranch={onCycleBranch} + /> + ) : null} {branchTransition ? ( <AnimatePresence initial={false} mode="wait"> @@ -244,7 +340,20 @@ export const AgentWorkspace = ({ transition={{ duration: 0.18, ease: "easeOut" }} style={{ display: "flex", flexDirection: "column", gap: 16 }} > - {transitionMessages.map(renderTurn)} + <TurnList + messages={transitionMessages} + branchGroups={branchGroups} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={onSpeak} + onPauseSpeech={onPauseSpeech} + onResumeSpeech={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={onRegenerate} + onEditResubmit={onEditResubmit} + onCycleBranch={onCycleBranch} + /> </motion.div> </AnimatePresence> ) : null} -- 2.54.0 From e60e1f6453dc8d6304a23b4a7fa47283e089ce44 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 4 Jun 2026 15:02:27 +0800 Subject: [PATCH 157/281] refactor: use backend chat sessions --- src/components/chat/AgentHistoryPanel.tsx | 3 - src/components/chat/GlobalChatbox.tsx | 24 +++---- src/components/chat/GlobalChatbox.types.ts | 26 +------- src/components/chat/chatStorage.test.ts | 34 +--------- src/components/chat/chatStorage.ts | 60 +++++++----------- .../chat/hooks/useAgentChatSession.test.tsx | 52 +++++++-------- .../chat/hooks/useAgentChatSession.ts | 63 +++++++------------ src/lib/chatStream.test.ts | 4 +- src/lib/chatStream.ts | 3 +- 9 files changed, 91 insertions(+), 178 deletions(-) diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index 79230a2..668c5a2 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -165,9 +165,6 @@ export const AgentHistoryPanel = ({ <Typography variant="subtitle2" fontWeight={800} color="text.primary"> 历史会话 </Typography> - <Typography variant="caption" color="text.secondary"> - 本地保存于浏览器 - </Typography> </Box> <Tooltip title="新建对话"> <motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index a199342..a56c79e 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -60,7 +60,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const { messages, chatSessions, - activeStorageSessionId, + activeSessionId, branchGroups, branchTransition, isHydrating, @@ -129,33 +129,33 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }, []); const handleSelectSession = useCallback( - (storageSessionId: string) => { + (sessionId: string) => { composerRef.current?.clear(); - void switchSession(storageSessionId); + void switchSession(sessionId); }, [switchSession], ); const handleDeleteSession = useCallback( - (storageSessionId: string) => { - void removeSession(storageSessionId); + (sessionId: string) => { + void removeSession(sessionId); }, [removeSession], ); const handleRenameSession = useCallback( - (storageSessionId: string, title: string) => { - void renameSession(storageSessionId, title); + (sessionId: string, title: string) => { + void renameSession(sessionId, title); }, [renameSession], ); const handleRenameActiveSession = useCallback( (title: string) => { - if (!activeStorageSessionId) return; - void renameSession(activeStorageSessionId, title); + if (!activeSessionId) return; + void renameSession(activeSessionId, title); }, - [activeStorageSessionId, renameSession], + [activeSessionId, renameSession], ); const handleMouseDown = useCallback((event: React.MouseEvent) => { @@ -255,7 +255,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <AgentHeader sessionTitle={sessionTitle} - canRenameSessionTitle={Boolean(activeStorageSessionId)} + canRenameSessionTitle={Boolean(activeSessionId)} isHydrating={isHydrating} isStreaming={isStreaming} isHistoryOpen={isHistoryOpen} @@ -294,7 +294,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { > <AgentHistoryPanel sessions={chatSessions} - activeSessionId={activeStorageSessionId} + activeSessionId={activeSessionId} isHydrating={isHydrating} onNewSession={() => { handleNewConversation(); diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index acba4e7..4ac085b 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -66,23 +66,6 @@ export type Props = { export type SpeechState = "idle" | "playing" | "paused"; -export type LegacyPersistedChatState = { - messages: Message[]; - sessionId?: string; - branchGroups?: BranchGroup[]; -}; - -export type ChatSessionRecord = { - id: string; - title: string; - isTitleManuallyEdited?: boolean; - createdAt: number; - updatedAt: number; - sessionId?: string; - messages: Message[]; - branchGroups: BranchGroup[]; -}; - export type ChatSessionSummary = { id: string; title: string; @@ -90,17 +73,10 @@ export type ChatSessionSummary = { updatedAt: number; }; -export type ChatStorageMeta = { - key: "chat-meta"; - activeSessionId?: string; - migratedFromLocalStorage?: boolean; -}; - export type LoadedChatState = { - storageSessionId?: string; + sessionId?: string; title?: string; isTitleManuallyEdited?: boolean; messages: Message[]; - sessionId?: string; branchGroups: BranchGroup[]; }; diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index ffbef3d..f1117c9 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -1,5 +1,5 @@ import { - loadActiveChatState, + createEmptyChatState, saveActiveChatState, } from "./chatStorage"; @@ -11,17 +11,13 @@ jest.mock("@/lib/apiFetch", () => ({ describe("chatStorage backend-only persistence", () => { beforeEach(() => { - window.localStorage.clear(); apiFetch.mockReset(); }); - it("starts from an empty conversation instead of restoring a stored active id", async () => { - window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1"); - - const loaded = await loadActiveChatState(); + it("creates an empty initial conversation state without backend calls", () => { + const loaded = createEmptyChatState(); expect(loaded).toMatchObject({ - storageSessionId: undefined, title: undefined, messages: [], sessionId: undefined, @@ -30,24 +26,6 @@ describe("chatStorage backend-only persistence", () => { expect(apiFetch).not.toHaveBeenCalled(); }); - it("starts from an empty conversation when a project has a stored active id", async () => { - window.localStorage.setItem( - "tjwater_agent_active_session_id_v2:project-a", - "chat-project-a", - ); - window.localStorage.setItem( - "tjwater_agent_active_session_id_v2:project-b", - "chat-project-b", - ); - - const loaded = await loadActiveChatState("project-b"); - - expect(loaded.storageSessionId).toBeUndefined(); - expect(loaded.title).toBeUndefined(); - expect(loaded.messages).toEqual([]); - expect(apiFetch).not.toHaveBeenCalled(); - }); - it("creates a backend conversation when saving the first non-empty state", async () => { apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { if (url.endsWith("/api/v1/agent/chat/session")) { @@ -75,7 +53,6 @@ describe("chatStorage backend-only persistence", () => { const savedSessionId = await saveActiveChatState( { - storageSessionId: undefined, title: "新对话", isTitleManuallyEdited: false, messages: [ @@ -89,13 +66,8 @@ describe("chatStorage backend-only persistence", () => { sessionId: undefined, branchGroups: [], }, - "project-a", ); expect(savedSessionId).toBe("chat-new-1"); - expect( - window.localStorage.getItem("tjwater_agent_active_session_id_v2:project-a"), - ).toBeNull(); }); - }); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index bda1fbc..5cfed97 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -9,15 +9,14 @@ import type { } from "./GlobalChatbox.types"; import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; -type RemoteSessionPayload = { +type BackendSessionPayload = { id?: string; title?: string; created_at?: string | number; updated_at?: string | number; }; -const emptyLoadedChatState = (): LoadedChatState => ({ - storageSessionId: undefined, +export const createEmptyChatState = (): LoadedChatState => ({ title: undefined, isTitleManuallyEdited: false, messages: [], @@ -58,7 +57,7 @@ const toMillis = (value: string | number | undefined) => const normalizeTitle = (value?: string) => value?.trim() || "新对话"; -const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => { +const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { method: "GET", projectHeaderMode: "include", @@ -69,7 +68,7 @@ const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => { throw new Error(await response.text()); } const payload = (await response.json()) as { - sessions?: RemoteSessionPayload[]; + sessions?: BackendSessionPayload[]; }; return (payload.sessions ?? []) .map((session) => ({ @@ -82,7 +81,7 @@ const fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => { .sort(compareSessionsByAnchorTime); }; -const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatState> => { +const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => { const response = await apiFetch( `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, { @@ -94,7 +93,7 @@ const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatStat ); if (!response.ok) { if (response.status === 404) { - return emptyLoadedChatState(); + return createEmptyChatState(); } throw new Error(await response.text()); } @@ -107,16 +106,15 @@ const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatStat branch_groups?: BranchGroup[]; }; return { - storageSessionId: payload.id, title: normalizeTitle(payload.title), isTitleManuallyEdited: payload.is_title_manually_edited ?? false, messages: sanitizeMessages(payload.messages), - sessionId: payload.session_id, + sessionId: payload.session_id ?? payload.id, branchGroups: sanitizeBranchGroups(payload.branch_groups), }; }; -const createRemoteChatSession = async (payload?: { +const createBackendChatSession = async (payload?: { sessionId?: string; parentSessionId?: string; }) => { @@ -146,7 +144,7 @@ const createRemoteChatSession = async (payload?: { return sessionId; }; -const saveRemoteChatState = async ( +const saveBackendChatState = async ( sessionId: string, state: LoadedChatState, ): Promise<string> => { @@ -175,7 +173,7 @@ const saveRemoteChatState = async ( return payload.id ?? payload.session_id ?? sessionId; }; -const updateRemoteChatSessionTitle = async ( +const updateBackendChatSessionTitle = async ( sessionId: string, title: string, isTitleManuallyEdited?: boolean, @@ -201,7 +199,7 @@ const updateRemoteChatSessionTitle = async ( } }; -const deleteRemoteChatSession = async (sessionId: string) => { +const deleteBackendChatSession = async (sessionId: string) => { const response = await apiFetch( `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, { @@ -216,42 +214,34 @@ const deleteRemoteChatSession = async (sessionId: string) => { } }; -export const loadActiveChatState = async ( - _projectId?: string | null, -): Promise<LoadedChatState> => { - return emptyLoadedChatState(); -}; - export const saveActiveChatState = async ( state: LoadedChatState, - _projectId?: string | null, ): Promise<string | undefined> => { - if (typeof window === "undefined") return state.storageSessionId; + if (typeof window === "undefined") return state.sessionId; if (!hasChatContent(state)) { return undefined; } - let remoteSessionId = state.sessionId ?? state.storageSessionId; - if (!remoteSessionId) { - remoteSessionId = await createRemoteChatSession(); + let backendSessionId = state.sessionId; + if (!backendSessionId) { + backendSessionId = await createBackendChatSession(); } - const savedSessionId = await saveRemoteChatState(remoteSessionId, { + const savedSessionId = await saveBackendChatState(backendSessionId, { ...state, - storageSessionId: remoteSessionId, - sessionId: remoteSessionId, + sessionId: backendSessionId, }); return savedSessionId; }; export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { if (typeof window === "undefined") return []; - return await fetchRemoteChatSessions(); + return await fetchBackendChatSessions(); }; export const updateChatSessionTitle = async ( - storageSessionId: string, + sessionId: string, title: string, options?: { isTitleManuallyEdited?: boolean; @@ -261,8 +251,8 @@ export const updateChatSessionTitle = async ( const normalizedTitle = title.trim(); if (!normalizedTitle) return; - await updateRemoteChatSessionTitle( - storageSessionId, + await updateBackendChatSessionTitle( + sessionId, normalizedTitle, options?.isTitleManuallyEdited, ); @@ -270,20 +260,18 @@ export const updateChatSessionTitle = async ( export const loadChatSessionById = async ( sessionId: string, - _projectId?: string | null, ): Promise<LoadedChatState> => { - if (typeof window === "undefined") return emptyLoadedChatState(); + if (typeof window === "undefined") return createEmptyChatState(); - return await fetchRemoteChatSession(sessionId); + return await fetchBackendChatSession(sessionId); }; export const deleteChatSession = async ( sessionId: string, - _projectId?: string | null, ): Promise<string | undefined> => { if (typeof window === "undefined") return undefined; - await deleteRemoteChatSession(sessionId); + await deleteBackendChatSession(sessionId); const nextActiveSession = (await listChatSessions())[0]; return nextActiveSession?.id; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index d321c55..eba37a8 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -12,44 +12,38 @@ jest.mock("@/lib/chatStream", () => ({ streamAgentChat: jest.fn(async () => undefined), })); -const loadActiveChatState = jest.fn(); const listChatSessions = jest.fn(); const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ - deleteChatSession: jest.fn(async () => undefined), - listChatSessions: (...args: unknown[]) => listChatSessions(...args), - loadActiveChatState: (...args: unknown[]) => loadActiveChatState(...args), - loadChatSessionById: jest.fn(async () => ({ - storageSessionId: "session-loaded", - title: "已存在会话", + createEmptyChatState: jest.fn(() => ({ + title: undefined, isTitleManuallyEdited: false, messages: [], sessionId: undefined, branchGroups: [], })), + deleteChatSession: jest.fn(async () => undefined), + listChatSessions: (...args: unknown[]) => listChatSessions(...args), + loadChatSessionById: jest.fn(async () => ({ + title: "已存在会话", + isTitleManuallyEdited: false, + messages: [], + sessionId: "session-loaded", + branchGroups: [], + })), saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); describe("useAgentChatSession", () => { beforeEach(() => { - loadActiveChatState.mockReset(); listChatSessions.mockReset(); saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(streamAgentChat).mockReset(); - saveActiveChatState.mockImplementation(async (state) => state.storageSessionId); - - loadActiveChatState.mockResolvedValue({ - storageSessionId: undefined, - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - branchGroups: [], - }); + saveActiveChatState.mockImplementation(async (state) => state.sessionId); }); it("does not add a new empty session to history until there is actual chat content", async () => { @@ -70,7 +64,7 @@ describe("useAgentChatSession", () => { await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); expect(result.current.chatSessions).toEqual([]); - expect(result.current.activeStorageSessionId).toBeUndefined(); + expect(result.current.activeSessionId).toBeUndefined(); expect(result.current.messages).toEqual([]); expect(result.current.isStreaming).toBe(false); expect(listChatSessions).toHaveBeenCalledTimes(1); @@ -164,14 +158,6 @@ describe("useAgentChatSession", () => { it("ignores generated session titles after the title was edited manually", async () => { listChatSessions.mockResolvedValue([]); - loadActiveChatState.mockResolvedValue({ - storageSessionId: "session-1", - title: "手动标题", - isTitleManuallyEdited: true, - messages: [], - sessionId: "session-1", - branchGroups: [], - }); jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "session_title", @@ -193,13 +179,23 @@ describe("useAgentChatSession", () => { await waitFor(() => expect(result.current.isHydrating).toBe(false)); + await act(async () => { + await result.current.switchSession("session-loaded"); + }); + + await act(async () => { + await result.current.renameSession("session-loaded", "手动标题"); + }); + + await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled()); + await act(async () => { await result.current.sendPrompt("帮我分析一下"); }); expect(result.current.sessionTitle).toBe("手动标题"); expect(updateChatSessionTitle).not.toHaveBeenCalledWith( - "session-1", + "session-loaded", "自动标题", expect.anything(), ); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index ac0feb8..8252bdf 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -19,9 +19,9 @@ import { createId, } from "../GlobalChatbox.utils"; import { + createEmptyChatState, deleteChatSession, listChatSessions, - loadActiveChatState, loadChatSessionById, saveActiveChatState, updateChatSessionTitle, @@ -50,7 +50,6 @@ type PromptRunOptions = { const createPersistedStateKey = (state: LoadedChatState) => JSON.stringify({ - storageSessionId: state.storageSessionId ?? null, title: state.title ?? null, isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, sessionId: state.sessionId ?? null, @@ -151,7 +150,6 @@ export const useAgentChatSession = ({ onBeforeSend, getModel, }: UseAgentChatSessionOptions) => { - const storageSessionIdRef = useRef<string | undefined>(undefined); const hydrationCompletedRef = useRef(false); const hydrationNonceRef = useRef(0); @@ -171,11 +169,10 @@ export const useAgentChatSession = ({ const titleUpdateNonceRef = useRef(0); const lastPersistedStateKeyRef = useRef( createPersistedStateKey({ - storageSessionId: undefined, + sessionId: undefined, title: undefined, isTitleManuallyEdited: false, messages: [], - sessionId: undefined, branchGroups: [], }), ); @@ -196,10 +193,8 @@ export const useAgentChatSession = ({ hydrationCompletedRef.current = false; if (!projectId) { - storageSessionIdRef.current = undefined; sessionIdRef.current = undefined; lastPersistedStateKeyRef.current = createPersistedStateKey({ - storageSessionId: undefined, title: undefined, isTitleManuallyEdited: false, messages: [], @@ -222,12 +217,11 @@ export const useAgentChatSession = ({ try { const [loadedState, sessions] = await Promise.all([ - loadActiveChatState(projectId), + Promise.resolve(createEmptyChatState()), listChatSessions(), ]); if (cancelled) return; - storageSessionIdRef.current = loadedState.storageSessionId; sessionIdRef.current = loadedState.sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState); hydrationCompletedRef.current = true; @@ -262,7 +256,6 @@ export const useAgentChatSession = ({ const currentHydrationNonce = hydrationNonceRef.current; const persistTimer = window.setTimeout(() => { const state: LoadedChatState = { - storageSessionId: storageSessionIdRef.current, title: sessionTitle, isTitleManuallyEdited: isSessionTitleManuallyEdited, messages, @@ -271,7 +264,6 @@ export const useAgentChatSession = ({ }; if ( isStreaming && - !state.storageSessionId && !state.sessionId && state.messages.length > 0 ) { @@ -283,13 +275,13 @@ export const useAgentChatSession = ({ return; } - void saveActiveChatState(state, projectId) - .then((storageSessionId) => { + void saveActiveChatState(state) + .then((sessionId) => { if (hydrationNonceRef.current !== currentHydrationNonce) return; - storageSessionIdRef.current = storageSessionId; + sessionIdRef.current = sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey({ ...state, - storageSessionId, + sessionId, }); return listChatSessions(); }) @@ -431,10 +423,10 @@ export const useAgentChatSession = ({ const nextTitle = event.title.trim(); if (nextTitle && !isSessionTitleManuallyEditedRef.current) { setSessionTitle(nextTitle); - const currentStorageSessionId = storageSessionIdRef.current; - if (currentStorageSessionId) { + const currentSessionId = sessionIdRef.current; + if (currentSessionId) { const currentNonce = ++titleUpdateNonceRef.current; - void updateChatSessionTitle(currentStorageSessionId, nextTitle, { + void updateChatSessionTitle(currentSessionId, nextTitle, { isTitleManuallyEdited: false, }) .then(() => listChatSessions()) @@ -555,10 +547,8 @@ export const useAgentChatSession = ({ setBranchTransition(null); hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - storageSessionIdRef.current = undefined; sessionIdRef.current = undefined; lastPersistedStateKeyRef.current = createPersistedStateKey({ - storageSessionId: undefined, title: "新对话", isTitleManuallyEdited: false, messages: [], @@ -574,21 +564,20 @@ export const useAgentChatSession = ({ }, [isHydrating, isStreaming]); const switchSession = useCallback( - async (nextStorageSessionId: string) => { - if (isHydrating || isStreaming || storageSessionIdRef.current === nextStorageSessionId) { + async (nextSessionId: string) => { + if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) { return; } setIsHydrating(true); try { const [nextState, sessions] = await Promise.all([ - loadChatSessionById(nextStorageSessionId, projectId), + loadChatSessionById(nextSessionId), listChatSessions(), ]); hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setBranchTransition(null); @@ -604,32 +593,29 @@ export const useAgentChatSession = ({ setIsHydrating(false); } }, - [isHydrating, isStreaming, projectId], + [isHydrating, isStreaming], ); const removeSession = useCallback( - async (targetStorageSessionId: string) => { + async (targetSessionId: string) => { if (isHydrating || isStreaming) return; try { const nextActiveSessionId = await deleteChatSession( - targetStorageSessionId, - projectId, + targetSessionId, ); const sessions = await listChatSessions(); setChatSessions(sessions); - if (storageSessionIdRef.current !== targetStorageSessionId) { + if (sessionIdRef.current !== targetSessionId) { return; } if (!nextActiveSessionId) { hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - storageSessionIdRef.current = undefined; sessionIdRef.current = undefined; lastPersistedStateKeyRef.current = createPersistedStateKey({ - storageSessionId: undefined, title: undefined, isTitleManuallyEdited: false, messages: [], @@ -647,12 +633,11 @@ export const useAgentChatSession = ({ setIsHydrating(true); const [nextState, sessionsAfterDelete] = await Promise.all([ - loadChatSessionById(nextActiveSessionId, projectId), + loadChatSessionById(nextActiveSessionId), listChatSessions(), ]); hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - storageSessionIdRef.current = nextState.storageSessionId; sessionIdRef.current = nextState.sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setBranchTransition(null); @@ -668,7 +653,7 @@ export const useAgentChatSession = ({ setIsHydrating(false); } }, - [isHydrating, isStreaming, projectId], + [isHydrating, isStreaming], ); const sendPrompt = useCallback( @@ -679,22 +664,22 @@ export const useAgentChatSession = ({ ); const renameSession = useCallback( - async (targetStorageSessionId: string, nextTitle: string) => { + async (targetSessionId: string, nextTitle: string) => { const normalizedTitle = nextTitle.trim(); if (!normalizedTitle || isHydrating) return; try { - await updateChatSessionTitle(targetStorageSessionId, normalizedTitle, { + await updateChatSessionTitle(targetSessionId, normalizedTitle, { isTitleManuallyEdited: true, }); const sessions = await listChatSessions(); setChatSessions(sessions); - if (storageSessionIdRef.current === targetStorageSessionId) { + if (sessionIdRef.current === targetSessionId) { setSessionTitle(normalizedTitle); setIsSessionTitleManuallyEdited(true); lastPersistedStateKeyRef.current = createPersistedStateKey({ - storageSessionId: targetStorageSessionId, + sessionId: targetSessionId, title: normalizedTitle, isTitleManuallyEdited: true, messages, @@ -864,7 +849,7 @@ export const useAgentChatSession = ({ return { messages, chatSessions, - activeStorageSessionId: storageSessionIdRef.current, + activeSessionId: sessionIdRef.current, branchGroups, branchTransition, isHydrating, diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 064ead6..79e400f 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -103,11 +103,11 @@ describe("streamAgentChat", () => { }); }); - it("parses legacy tool_call arguments when params is empty", async () => { + it("parses tool_call arguments when params is empty", async () => { apiFetch.mockResolvedValue({ ok: true, body: makeStream([ - 'event: tool_call\ndata: {"conversationId":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n', + 'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n', 'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n', ]), }); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 9a6d981..4fd94d5 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -163,7 +163,6 @@ export const streamAgentChat = async ({ try { const parsed = JSON.parse(data) as { session_id?: string; - conversationId?: string; content?: string; message?: string; detail?: string; @@ -223,7 +222,7 @@ export const streamAgentChat = async ({ } else if (event === "tool_call") { onEvent({ type: "tool_call", - sessionId: parsed.session_id ?? parsed.conversationId ?? "", + sessionId: parsed.session_id ?? "", tool: parsed.tool ?? "", params: resolveToolParams(parsed.params, parsed.arguments), }); -- 2.54.0 From 7764e253983cb43b047f93538a642a09af7849ba Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 4 Jun 2026 16:27:15 +0800 Subject: [PATCH 158/281] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E4=B8=AD=E6=96=AD=E5=A4=84=E7=90=86=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.types.ts | 4 + src/components/chat/chatStorage.ts | 8 + .../chat/hooks/useAgentChatSession.test.tsx | 137 ++++++++- .../chat/hooks/useAgentChatSession.ts | 288 +++++++++++------- src/lib/chatStream.test.ts | 52 +++- src/lib/chatStream.ts | 267 ++++++++++------ 6 files changed, 559 insertions(+), 197 deletions(-) diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 4ac085b..8a580b5 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -71,6 +71,8 @@ export type ChatSessionSummary = { title: string; createdAt: number; updatedAt: number; + isStreaming?: boolean; + runStatus?: string; }; export type LoadedChatState = { @@ -79,4 +81,6 @@ export type LoadedChatState = { isTitleManuallyEdited?: boolean; messages: Message[]; branchGroups: BranchGroup[]; + isStreaming?: boolean; + runStatus?: string; }; diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 5cfed97..81d5506 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -14,6 +14,8 @@ type BackendSessionPayload = { title?: string; created_at?: string | number; updated_at?: string | number; + is_streaming?: boolean; + run_status?: string; }; export const createEmptyChatState = (): LoadedChatState => ({ @@ -76,6 +78,8 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => { title: normalizeTitle(session.title), createdAt: toMillis(session.created_at), updatedAt: toMillis(session.updated_at), + isStreaming: session.is_streaming, + runStatus: session.run_status, })) .filter((session) => Boolean(session.id)) .sort(compareSessionsByAnchorTime); @@ -104,6 +108,8 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta session_id?: string; messages?: Message[]; branch_groups?: BranchGroup[]; + is_streaming?: boolean; + run_status?: string; }; return { title: normalizeTitle(payload.title), @@ -111,6 +117,8 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta messages: sanitizeMessages(payload.messages), sessionId: payload.session_id ?? payload.id, branchGroups: sanitizeBranchGroups(payload.branch_groups), + isStreaming: payload.is_streaming ?? false, + runStatus: payload.run_status, }; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index eba37a8..8308539 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -3,12 +3,13 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; -import { streamAgentChat } from "@/lib/chatStream"; +import { abortAgentChat, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), forkAgentChat: jest.fn(async () => "forked-session"), + resumeAgentChatStream: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined), })); @@ -42,7 +43,12 @@ describe("useAgentChatSession", () => { listChatSessions.mockReset(); saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); + jest.mocked(abortAgentChat).mockReset(); + jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); + jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); + jest.mocked(streamAgentChat).mockImplementation(async () => undefined); saveActiveChatState.mockImplementation(async (state) => state.sessionId); }); @@ -103,7 +109,7 @@ describe("useAgentChatSession", () => { ]); }); - it("waits for the stream session id before persisting a new streaming conversation", async () => { + it("persists a new conversation only after the stream is done", async () => { listChatSessions.mockResolvedValue([]); let emitStreamEvent: ((event: StreamEvent) => void) | undefined; jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { @@ -147,15 +153,140 @@ describe("useAgentChatSession", () => { jest.advanceTimersByTime(200); }); - expect(saveActiveChatState).toHaveBeenCalledTimes(1); + expect(saveActiveChatState).not.toHaveBeenCalled(); + + act(() => { + emitStreamEvent?.({ + type: "done", + sessionId: "chat-stream-1", + }); + }); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1)); expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ sessionId: "chat-stream-1", + messages: [ + expect.objectContaining({ role: "user", content: "第一条消息" }), + expect.objectContaining({ role: "assistant", content: "收到" }), + ], }); } finally { jest.useRealTimers(); } }); + it("hydrates a backend streaming session and resumes its stream", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + runStatus: "running", + }, + ]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.activeSessionId).toBe("session-loaded"); + expect(resumeAgentChatStream).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-loaded", + }), + ); + }); + + it("updates resumed messages from state, token, and done events", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { id: "a1", role: "assistant", content: "已有" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "token", + sessionId: "session-loaded", + content: "输出", + }); + onEvent({ + type: "done", + sessionId: "session-loaded", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }), + expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }), + ]); + }); + + it("aborts a resumed streaming session through the backend abort endpoint", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => { + await new Promise<void>(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + act(() => { + result.current.abort(); + }); + + expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); + }); + it("ignores generated session titles after the title was edited manually", async () => { listChatSessions.mockResolvedValue([]); jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 8252bdf..2cd64c5 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream"; +import { + abortAgentChat, + forkAgentChat, + resumeAgentChatStream, + streamAgentChat, +} from "@/lib/chatStream"; import type { AgentModel, StreamEvent } from "@/lib/chatStream"; import type { AgentArtifact, @@ -164,6 +169,8 @@ export const useAgentChatSession = ({ const [isHydrating, setIsHydrating] = useState(true); const abortRef = useRef<AbortController | null>(null); const sessionIdRef = useRef<string | undefined>(undefined); + const messagesRef = useRef<Message[]>([]); + const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null); const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef<Promise<void> | null>(null); const titleUpdateNonceRef = useRef(0); @@ -181,6 +188,10 @@ export const useAgentChatSession = ({ sessionIdRef.current = sessionId; }, [sessionId]); + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + useEffect(() => { isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; }, [isSessionTitleManuallyEdited]); @@ -216,10 +227,11 @@ export const useAgentChatSession = ({ } try { - const [loadedState, sessions] = await Promise.all([ - Promise.resolve(createEmptyChatState()), - listChatSessions(), - ]); + const sessions = await listChatSessions(); + const streamingSession = sessions.find((session) => session.isStreaming); + const loadedState = streamingSession + ? await loadChatSessionById(streamingSession.id) + : createEmptyChatState(); if (cancelled) return; sessionIdRef.current = loadedState.sessionId; @@ -234,6 +246,12 @@ export const useAgentChatSession = ({ setSessionId(loadedState.sessionId); setBranchGroups(loadedState.branchGroups); setChatSessions(sessions); + if ( + loadedState.sessionId && + (loadedState.isStreaming || streamingSession?.isStreaming) + ) { + resumeStreamingSessionRef.current?.(loadedState.sessionId); + } } catch (error) { console.error("[GlobalChatbox] Failed to hydrate chat state:", error); } finally { @@ -255,6 +273,10 @@ export const useAgentChatSession = ({ const currentHydrationNonce = hydrationNonceRef.current; const persistTimer = window.setTimeout(() => { + if (isStreaming) { + return; + } + const state: LoadedChatState = { title: sessionTitle, isTitleManuallyEdited: isSessionTitleManuallyEdited, @@ -262,13 +284,6 @@ export const useAgentChatSession = ({ sessionId, branchGroups, }; - if ( - isStreaming && - !state.sessionId && - state.messages.length > 0 - ) { - return; - } const currentStateKey = createPersistedStateKey(state); if (currentStateKey === lastPersistedStateKeyRef.current) { @@ -351,6 +366,150 @@ export const useAgentChatSession = ({ ); }, []); + const getLastAssistantMessageId = useCallback((fallback?: string) => { + const assistant = [...messagesRef.current] + .reverse() + .find((message) => message.role === "assistant"); + return assistant?.id ?? fallback; + }, []); + + const applyStreamEvent = useCallback( + ( + event: StreamEvent, + options?: { + assistantMessageId?: string; + }, + ) => { + if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) { + sessionIdRef.current = event.sessionId; + setSessionId(event.sessionId); + } + + if (event.type === "state") { + const nextMessages = cloneMessages(event.messages as Message[]); + messagesRef.current = nextMessages; + setMessages(nextMessages); + setIsStreaming(event.isStreaming); + return; + } + + const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId); + if (!assistantMessageId) { + return; + } + + if (event.type === "token") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: message.content + event.content, + isError: false, + } + : message, + ), + ); + } else if (event.type === "progress") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { ...message, progress: upsertProgress(message.progress, event) } + : message, + ), + ); + } else if (event.type === "tool_call") { + onToolCall(event, { + assistantMessageId, + appendArtifact, + }); + } else if (event.type === "session_title") { + const nextTitle = event.title.trim(); + if (nextTitle && !isSessionTitleManuallyEditedRef.current) { + setSessionTitle(nextTitle); + const currentSessionId = sessionIdRef.current; + if (currentSessionId) { + const currentNonce = ++titleUpdateNonceRef.current; + void updateChatSessionTitle(currentSessionId, nextTitle, { + isTitleManuallyEdited: false, + }) + .then(() => listChatSessions()) + .then((sessions) => { + if (titleUpdateNonceRef.current !== currentNonce) return; + setChatSessions(sessions); + }) + .catch((error) => { + console.error("[GlobalChatbox] Failed to persist session title:", error); + }); + } + } + } else if (event.type === "done") { + setMessages((prev) => + prev.map((message) => { + if (message.id !== assistantMessageId) return message; + const completedProgress = completeRunningProgress(message.progress); + if ( + message.content.trim().length === 0 && + !(message.artifacts?.length) + ) { + return { + ...message, + content: + "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", + progress: completedProgress, + }; + } + return { ...message, progress: completedProgress }; + }), + ); + setIsStreaming(false); + } else if (event.type === "error") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: message.content || `⚠️ **错误:** ${event.message}`, + isError: true, + progress: completeRunningProgress(message.progress), + } + : message, + ), + ); + setIsStreaming(false); + } + }, + [appendArtifact, getLastAssistantMessageId, onToolCall], + ); + + const resumeStreamingSession = useCallback( + (nextSessionId: string) => { + const controller = new AbortController(); + abortRef.current?.abort(); + abortRef.current = controller; + setIsStreaming(true); + + void resumeAgentChatStream({ + sessionId: nextSessionId, + signal: controller.signal, + onEvent: (event) => applyStreamEvent(event), + }) + .catch((error) => { + if (!controller.signal.aborted) { + console.error("[GlobalChatbox] Failed to resume chat stream:", error); + setIsStreaming(false); + } + }) + .finally(() => { + if (abortRef.current === controller) { + abortRef.current = null; + } + }); + }, + [applyStreamEvent], + ); + resumeStreamingSessionRef.current = resumeStreamingSession; + const runPrompt = useCallback( async ({ prompt: rawPrompt, @@ -372,8 +531,10 @@ export const useAgentChatSession = ({ preparedMessages ?? [...messages, nextUserMessage, nextAssistantMessage]; + const clonedNextMessages = cloneMessages(nextMessages); setIsStreaming(true); - setMessages(cloneMessages(nextMessages)); + messagesRef.current = clonedNextMessages; + setMessages(clonedNextMessages); if (sessionIdOverride !== undefined) { sessionIdRef.current = sessionIdOverride; setSessionId(sessionIdOverride); @@ -388,93 +549,10 @@ export const useAgentChatSession = ({ sessionId: sessionIdOverride ?? sessionIdRef.current, model: getModel?.(), signal: controller.signal, - onEvent: (event) => { - if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) { - sessionIdRef.current = event.sessionId; - setSessionId(event.sessionId); - } - - if (event.type === "token") { - setMessages((prev) => - prev.map((message) => - message.id === nextAssistantMessage.id - ? { - ...message, - content: message.content + event.content, - isError: false, - } - : message, - ), - ); - } else if (event.type === "progress") { - setMessages((prev) => - prev.map((message) => - message.id === nextAssistantMessage.id - ? { ...message, progress: upsertProgress(message.progress, event) } - : message, - ), - ); - } else if (event.type === "tool_call") { - onToolCall(event, { - assistantMessageId: nextAssistantMessage.id, - appendArtifact, - }); - } else if (event.type === "session_title") { - const nextTitle = event.title.trim(); - if (nextTitle && !isSessionTitleManuallyEditedRef.current) { - setSessionTitle(nextTitle); - const currentSessionId = sessionIdRef.current; - if (currentSessionId) { - const currentNonce = ++titleUpdateNonceRef.current; - void updateChatSessionTitle(currentSessionId, nextTitle, { - isTitleManuallyEdited: false, - }) - .then(() => listChatSessions()) - .then((sessions) => { - if (titleUpdateNonceRef.current !== currentNonce) return; - setChatSessions(sessions); - }) - .catch((error) => { - console.error("[GlobalChatbox] Failed to persist session title:", error); - }); - } - } - } else if (event.type === "done") { - setMessages((prev) => - prev.map((message) => { - if (message.id !== nextAssistantMessage.id) return message; - const completedProgress = completeRunningProgress(message.progress); - if ( - message.content.trim().length === 0 && - !(message.artifacts?.length) - ) { - return { - ...message, - content: - "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", - progress: completedProgress, - }; - } - return { ...message, progress: completedProgress }; - }), - ); - setIsStreaming(false); - } else if (event.type === "error") { - setMessages((prev) => - prev.map((message) => - message.id === nextAssistantMessage.id - ? { - ...message, - content: message.content || `⚠️ **错误:** ${event.message}`, - isError: true, - progress: completeRunningProgress(message.progress), - } - : message, - ), - ); - setIsStreaming(false); - } - }, + onEvent: (event) => + applyStreamEvent(event, { + assistantMessageId: nextAssistantMessage.id, + }), }); } catch (error) { if (controller.signal.aborted) { @@ -520,7 +598,7 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, getModel, isHydrating, isStreaming, messages, onBeforeSend, onToolCall], + [applyStreamEvent, getModel, isHydrating, isStreaming, messages, onBeforeSend], ); const abort = useCallback(() => { @@ -587,13 +665,18 @@ export const useAgentChatSession = ({ setSessionId(nextState.sessionId); setBranchGroups(nextState.branchGroups); setChatSessions(sessions); + if (nextState.sessionId && nextState.isStreaming) { + resumeStreamingSession(nextState.sessionId); + } else { + setIsStreaming(false); + } } catch (error) { console.error("[GlobalChatbox] Failed to switch chat session:", error); } finally { setIsHydrating(false); } }, - [isHydrating, isStreaming], + [isHydrating, isStreaming, resumeStreamingSession], ); const removeSession = useCallback( @@ -683,7 +766,6 @@ export const useAgentChatSession = ({ title: normalizedTitle, isTitleManuallyEdited: true, messages, - sessionId: sessionIdRef.current, branchGroups, }); } diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 79e400f..64a6c62 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -1,4 +1,9 @@ -import { abortAgentChat, forkAgentChat, streamAgentChat } from "./chatStream"; +import { + abortAgentChat, + forkAgentChat, + resumeAgentChatStream, + streamAgentChat, +} from "./chatStream"; import { ReadableStream } from "stream/web"; import { TextEncoder, TextDecoder } from "util"; @@ -76,6 +81,51 @@ describe("streamAgentChat", () => { ]); }); + it("parses state events from a resumed stream", async () => { + apiFetch.mockResolvedValue({ + ok: true, + body: makeStream([ + 'event: state\ndata: {"session_id":"s1","messages":[{"id":"a1","role":"assistant","content":"已输出"}],"is_streaming":true,"run_status":"running"}\n\n', + 'event: token\ndata: {"session_id":"s1","content":"继续"}\n\n', + 'event: done\ndata: {"session_id":"s1"}\n\n', + ]), + }); + + const events: Array<{ + type: string; + sessionId?: string; + messages?: unknown[]; + isStreaming?: boolean; + runStatus?: string; + content?: string; + }> = []; + + await resumeAgentChatStream({ + sessionId: "s1", + onEvent: (event) => events.push(event), + }); + + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/session/s1/stream"), + expect.objectContaining({ + method: "GET", + projectHeaderMode: "include", + skipAuthRedirect: true, + }), + ); + expect(events).toEqual([ + { + type: "state", + sessionId: "s1", + messages: [{ id: "a1", role: "assistant", content: "已输出" }], + isStreaming: true, + runStatus: "running", + }, + { type: "token", sessionId: "s1", content: "继续" }, + { type: "done", sessionId: "s1" }, + ]); + }); + it("parses progress events", async () => { apiFetch.mockResolvedValue({ ok: true, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 4fd94d5..a6581e5 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -6,6 +6,13 @@ export type AgentModel = | "deepseek/deepseek-v4-pro"; export type StreamEvent = + | { + type: "state"; + sessionId: string; + messages: unknown[]; + isStreaming: boolean; + runStatus?: string; + } | { type: "token"; sessionId: string; content: string } | { type: "done"; sessionId: string; totalDurationMs?: number } | { type: "session_title"; sessionId: string; title: string } @@ -44,6 +51,12 @@ type StreamOptions = { onEvent: (event: StreamEvent) => void; }; +type ResumeStreamOptions = { + sessionId: string; + signal?: AbortSignal; + onEvent: (event: StreamEvent) => void; +}; + const parseEventBlock = (block: string): { event?: string; data?: string } => { const lines = block.split("\n"); let event: string | undefined; @@ -87,6 +100,126 @@ const resolveToolParams = ( return isObjectRecord(params) ? params : {}; }; +const emitParsedStreamEvent = ( + event: string, + data: string, + onEvent: (event: StreamEvent) => void, +) => { + try { + const parsed = JSON.parse(data) as { + session_id?: string; + content?: string; + message?: string; + detail?: string; + tool?: string; + params?: Record<string, unknown>; + arguments?: unknown; + id?: string; + phase?: string; + status?: "running" | "completed" | "error"; + title?: string; + messages?: unknown[]; + is_streaming?: boolean; + run_status?: string; + started_at?: number; + ended_at?: number; + elapsed_ms?: number; + duration_ms?: number; + total_duration_ms?: number; + }; + if (event === "state") { + onEvent({ + type: "state", + sessionId: parsed.session_id ?? "", + messages: Array.isArray(parsed.messages) ? parsed.messages : [], + isStreaming: parsed.is_streaming ?? false, + runStatus: parsed.run_status, + }); + } else if (event === "token") { + onEvent({ + type: "token", + sessionId: parsed.session_id ?? "", + content: parsed.content ?? "", + }); + } else if (event === "progress") { + onEvent({ + type: "progress", + sessionId: parsed.session_id ?? "", + id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`, + phase: parsed.phase ?? "progress", + status: parsed.status ?? "running", + title: parsed.title ?? "正在处理", + detail: parsed.detail, + startedAt: parsed.started_at, + endedAt: parsed.ended_at, + elapsedMs: parsed.elapsed_ms, + durationMs: parsed.duration_ms, + }); + } else if (event === "done") { + onEvent({ + type: "done", + sessionId: parsed.session_id ?? "", + totalDurationMs: parsed.total_duration_ms, + }); + } else if (event === "session_title") { + onEvent({ + type: "session_title", + sessionId: parsed.session_id ?? "", + title: typeof parsed.title === "string" ? parsed.title : "", + }); + } else if (event === "error") { + onEvent({ + type: "error", + sessionId: parsed.session_id, + message: parsed.message ?? "unknown error", + detail: parsed.detail, + totalDurationMs: parsed.total_duration_ms, + }); + } else if (event === "tool_call") { + onEvent({ + type: "tool_call", + sessionId: parsed.session_id ?? "", + tool: parsed.tool ?? "", + params: resolveToolParams(parsed.params, parsed.arguments), + }); + } + } catch { + onEvent({ + type: "error", + message: "invalid SSE data payload", + detail: data, + }); + } +}; + +const readStreamEvents = async ( + response: Response, + onEvent: (event: StreamEvent) => void, +) => { + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() ?? ""; + + for (const block of blocks) { + const { event, data } = parseEventBlock(block); + if (!event || !data) continue; + emitParsedStreamEvent(event, data, onEvent); + } + } +}; + export const streamAgentChat = async ({ message, sessionId, @@ -144,98 +277,52 @@ export const streamAgentChat = async ({ return; } - const reader = response.body.getReader(); - const decoder = new TextDecoder("utf-8"); - let buffer = ""; + await readStreamEvents(response, onEvent); +}; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const blocks = buffer.split("\n\n"); - buffer = blocks.pop() ?? ""; - - for (const block of blocks) { - const { event, data } = parseEventBlock(block); - if (!event || !data) continue; - - try { - const parsed = JSON.parse(data) as { - session_id?: string; - content?: string; - message?: string; - detail?: string; - tool?: string; - params?: Record<string, unknown>; - arguments?: unknown; - id?: string; - phase?: string; - status?: "running" | "completed" | "error"; - title?: string; - started_at?: number; - ended_at?: number; - elapsed_ms?: number; - duration_ms?: number; - total_duration_ms?: number; - }; - if (event === "token") { - onEvent({ - type: "token", - sessionId: parsed.session_id ?? "", - content: parsed.content ?? "", - }); - } else if (event === "progress") { - onEvent({ - type: "progress", - sessionId: parsed.session_id ?? "", - id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`, - phase: parsed.phase ?? "progress", - status: parsed.status ?? "running", - title: parsed.title ?? "正在处理", - detail: parsed.detail, - startedAt: parsed.started_at, - endedAt: parsed.ended_at, - elapsedMs: parsed.elapsed_ms, - durationMs: parsed.duration_ms, - }); - } else if (event === "done") { - onEvent({ - type: "done", - sessionId: parsed.session_id ?? "", - totalDurationMs: parsed.total_duration_ms, - }); - } else if (event === "session_title") { - onEvent({ - type: "session_title", - sessionId: parsed.session_id ?? "", - title: typeof parsed.title === "string" ? parsed.title : "", - }); - } else if (event === "error") { - onEvent({ - type: "error", - sessionId: parsed.session_id, - message: parsed.message ?? "unknown error", - detail: parsed.detail, - totalDurationMs: parsed.total_duration_ms, - }); - } else if (event === "tool_call") { - onEvent({ - type: "tool_call", - sessionId: parsed.session_id ?? "", - tool: parsed.tool ?? "", - params: resolveToolParams(parsed.params, parsed.arguments), - }); - } - } catch { - onEvent({ - type: "error", - message: "invalid SSE data payload", - detail: data, - }); - } - } +export const resumeAgentChatStream = async ({ + sessionId, + signal, + onEvent, +}: ResumeStreamOptions) => { + let response: Response; + try { + response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/stream`, + { + method: "GET", + signal, + headers: { + Accept: "text/event-stream", + }, + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + onEvent({ + type: "error", + sessionId, + message: "network request failed", + detail, + }); + return; } + + if (!response.ok || !response.body) { + const detail = await response.text(); + onEvent({ + type: "error", + sessionId, + message: "stream request failed", + detail, + }); + return; + } + + await readStreamEvents(response, onEvent); }; export const abortAgentChat = async (sessionId?: string) => { -- 2.54.0 From 57369772c7c5c79f32430139f0c9338fcf9cdefe Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 4 Jun 2026 18:02:38 +0800 Subject: [PATCH 159/281] =?UTF-8?q?fix(chat):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=BF=9B=E5=BA=A6=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentProgressTimeline.test.tsx | 8 ++++---- src/components/chat/AgentProgressTimeline.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/chat/AgentProgressTimeline.test.tsx b/src/components/chat/AgentProgressTimeline.test.tsx index b1e7b54..5c6a736 100644 --- a/src/components/chat/AgentProgressTimeline.test.tsx +++ b/src/components/chat/AgentProgressTimeline.test.tsx @@ -30,8 +30,8 @@ describe("AgentProgressTimeline", () => { id: "tool", phase: "tool", status: "running", - title: "正在调用 dynamic_http_call", - detail: "GET /api/v1/network/bottlenecks", + title: "正在调用 tjwater_cli", + detail: "analysis bottlenecks", startedAt: now - 1200, elapsedMs: 1200, elapsedSnapshotAt: now, @@ -43,7 +43,7 @@ describe("AgentProgressTimeline", () => { expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument(); expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument(); expect(screen.getByText("查询后端数据")).toBeInTheDocument(); - expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument(); + expect(screen.getByText("analysis bottlenecks")).toBeInTheDocument(); expect(screen.getByText("1.2s")).toBeInTheDocument(); }); @@ -86,7 +86,7 @@ describe("AgentProgressTimeline", () => { id: "tool", phase: "tool", status: "completed", - title: "正在调用 dynamic_http_call", + title: "正在调用 tjwater_cli", startedAt: Date.now() - 4000, endedAt: Date.now(), }, diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index 83c99e6..15bba4d 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -76,7 +76,7 @@ const phaseIcon = (phase: string, status: ChatProgress["status"]) => { const formatToolTitle = (item: ChatProgress) => { const text = `${item.title} ${item.detail ?? ""}`; - if (text.includes("dynamic_http_call")) return "查询后端数据"; + if (text.includes("tjwater_cli")) return "查询后端数据"; if (text.includes("show_chart")) return "生成图表"; if (text.includes("locate_features")) return "地图定位"; if (text.includes("view_history")) return "打开历史曲线"; -- 2.54.0 From 709b029c4e67b9555fb66d25c786909b7950c953 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 5 Jun 2026 13:06:20 +0800 Subject: [PATCH 160/281] =?UTF-8?q?fix(chat)=EF=BC=9A=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E5=89=8D=E8=BF=9B=E8=A1=8C=20token=20?= =?UTF-8?q?=E6=9C=89=E6=95=88=E6=80=A7=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/GlobalChatbox.tsx | 38 +++++++++-- .../chat/hooks/useAgentChatSession.test.tsx | 68 ++++++++++++++++++- .../chat/hooks/useAgentChatSession.ts | 9 +++ 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index a56c79e..eb81823 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -7,7 +7,9 @@ import React, { useState, } from "react"; import { Box, Drawer, alpha, useTheme } from "@mui/material"; +import { useNotification } from "@refinedev/core"; +import { getAccessToken } from "@/lib/authToken"; import type { AgentModel } from "@/lib/chatStream"; import { useProjectStore } from "@/store/projectStore"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; @@ -25,6 +27,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [isCheckingAuth, setIsCheckingAuth] = useState(false); const [selectedModel, setSelectedModel] = useState<AgentModel>( "deepseek/deepseek-v4-pro", ); @@ -33,6 +36,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const composerRef = useRef<AgentComposerHandle | null>(null); const hasResetForOpenRef = useRef(false); const theme = useTheme(); + const { open: openNotification } = useNotification(); const currentProjectId = useProjectStore((state) => state.currentProjectId); const { @@ -108,10 +112,34 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { return () => window.clearTimeout(timer); }, [createSession, isHydrating, open, scrollToBottom]); - const handleSend = useCallback((prompt: string) => { - if (isStreaming) return; - void sendPrompt(prompt); - }, [isStreaming, sendPrompt]); + const handleSend = useCallback(async (prompt: string) => { + if (isStreaming || isCheckingAuth) return; + + setIsCheckingAuth(true); + try { + const accessToken = await getAccessToken(); + if (!accessToken) { + composerRef.current?.setValue(prompt); + openNotification?.({ + type: "error", + message: "登录状态已失效", + description: "请重新登录后再发送对话。", + }); + return; + } + + void sendPrompt(prompt); + } catch (error) { + composerRef.current?.setValue(prompt); + openNotification?.({ + type: "error", + message: "登录状态校验失败", + description: error instanceof Error ? error.message : "请重新登录后再试。", + }); + } finally { + setIsCheckingAuth(false); + } + }, [isCheckingAuth, isStreaming, openNotification, sendPrompt]); const handleNewConversation = useCallback(() => { handleStopSpeech(); @@ -330,7 +358,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <AgentComposer ref={composerRef} - isHydrating={isHydrating} + isHydrating={isHydrating || isCheckingAuth} isStreaming={isStreaming} isListening={isListening} isSttSupported={isSttSupported} diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 8308539..10a239d 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -14,6 +14,7 @@ jest.mock("@/lib/chatStream", () => ({ })); const listChatSessions = jest.fn(); +const deleteChatSession = jest.fn(); const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); @@ -25,7 +26,7 @@ jest.mock("../chatStorage", () => ({ sessionId: undefined, branchGroups: [], })), - deleteChatSession: jest.fn(async () => undefined), + deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), listChatSessions: (...args: unknown[]) => listChatSessions(...args), loadChatSessionById: jest.fn(async () => ({ title: "已存在会话", @@ -41,6 +42,7 @@ jest.mock("../chatStorage", () => ({ describe("useAgentChatSession", () => { beforeEach(() => { listChatSessions.mockReset(); + deleteChatSession.mockReset(); saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); @@ -49,6 +51,7 @@ describe("useAgentChatSession", () => { jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); + deleteChatSession.mockImplementation(async () => undefined); saveActiveChatState.mockImplementation(async (state) => state.sessionId); }); @@ -109,6 +112,69 @@ describe("useAgentChatSession", () => { ]); }); + it("removes a deleted history entry before the backend delete finishes", async () => { + const initialSessions = [ + { + id: "session-1", + title: "第一段会话", + createdAt: 2, + updatedAt: 2, + }, + { + id: "session-2", + title: "第二段会话", + createdAt: 1, + updatedAt: 1, + }, + ]; + let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined; + + listChatSessions.mockResolvedValue(initialSessions); + deleteChatSession.mockImplementationOnce( + () => + new Promise<string | undefined>((resolve) => { + resolveDelete = resolve; + }), + ); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.removeSession("session-2"); + }); + + expect(result.current.chatSessions).toEqual([ + expect.objectContaining({ id: "session-1" }), + ]); + + listChatSessions.mockResolvedValue([ + { + id: "session-1", + title: "第一段会话", + createdAt: 2, + updatedAt: 2, + }, + ]); + + await act(async () => { + resolveDelete?.(); + await Promise.resolve(); + }); + + await waitFor(() => + expect(result.current.chatSessions).toEqual([ + expect.objectContaining({ id: "session-1" }), + ]), + ); + }); + it("persists a new conversation only after the stream is done", async () => { listChatSessions.mockResolvedValue([]); let emitStreamEvent: ((event: StreamEvent) => void) | undefined; diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 2cd64c5..cb8150a 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -683,6 +683,10 @@ export const useAgentChatSession = ({ async (targetSessionId: string) => { if (isHydrating || isStreaming) return; + setChatSessions((prev) => + prev.filter((session) => session.id !== targetSessionId), + ); + try { const nextActiveSessionId = await deleteChatSession( targetSessionId, @@ -732,6 +736,11 @@ export const useAgentChatSession = ({ setChatSessions(sessionsAfterDelete); } catch (error) { console.error("[GlobalChatbox] Failed to delete chat session:", error); + try { + setChatSessions(await listChatSessions()); + } catch (refreshError) { + console.error("[GlobalChatbox] Failed to refresh chat sessions:", refreshError); + } } finally { setIsHydrating(false); } -- 2.54.0 From 5fc1812d5359eae2357ed0bcfdf0b36dcbee4208 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 5 Jun 2026 13:08:56 +0800 Subject: [PATCH 161/281] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=20abort?= =?UTF-8?q?=20=E5=90=8E=20progress=20=E4=BB=8D=E6=98=BE=E7=A4=BA=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E4=B8=AD=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/hooks/useAgentChatSession.test.tsx | 60 +++++++++++++++++++ .../chat/hooks/useAgentChatSession.ts | 32 +++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 10a239d..8e3d84d 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -353,6 +353,66 @@ describe("useAgentChatSession", () => { expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); }); + it("finalizes running progress when aborting an active prompt", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat).mockImplementationOnce( + ({ onEvent, signal }) => + new Promise<void>((_, reject) => { + onEvent({ + type: "progress", + sessionId: "session-1", + id: "request-received", + phase: "start", + status: "running", + title: "开始分析", + startedAt: 1000, + } satisfies StreamEvent); + + signal.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }), + ); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.sendPrompt("测试中断"); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + act(() => { + result.current.abort(); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.messages.at(-1)).toEqual( + expect.objectContaining({ + role: "assistant", + content: "⚠️ **请求已中断**", + isError: true, + progress: [ + expect.objectContaining({ + id: "request-received", + status: "completed", + durationMs: expect.any(Number), + endedAt: expect.any(Number), + }), + ], + }), + ); + expect(abortAgentChat).toHaveBeenCalledWith("session-1"); + }); + it("ignores generated session titles after the title was edited manually", async () => { listChatSessions.mockResolvedValue([]); jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index cb8150a..7694f6e 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -130,6 +130,25 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) => }; }); +const finalizeAssistantMessageAfterAbort = (message: Message): Message => { + const completedProgress = completeRunningProgress(message.progress); + const hasVisibleOutput = + message.content.trim().length > 0 || + Boolean(message.artifacts?.length) || + Boolean(completedProgress?.length); + + if (!hasVisibleOutput) { + return message; + } + + return { + ...message, + content: message.content || "⚠️ **请求已中断**", + isError: true, + progress: completedProgress, + }; +}; + const createUserMessage = (content: string, branchRootId?: string): Message => { const id = createId(); return { @@ -605,6 +624,17 @@ export const useAgentChatSession = ({ const controller = abortRef.current; controller?.abort(); setIsStreaming(false); + const assistantMessageId = getLastAssistantMessageId(); + + if (assistantMessageId) { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? finalizeAssistantMessageAfterAbort(message) + : message, + ), + ); + } const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => { console.error("[GlobalChatbox] Failed to abort agent session:", error); @@ -615,7 +645,7 @@ export const useAgentChatSession = ({ } }); cancelPromiseRef.current = trackedCancelPromise; - }, []); + }, [getLastAssistantMessageId]); const createSession = useCallback(() => { if (isHydrating || isStreaming) return; -- 2.54.0 From e32823e4b584e704c306e162ffbbd3cecfb0b064 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 13:32:50 +0800 Subject: [PATCH 162/281] feat: add permission request UI --- src/components/chat/AgentTurn.tsx | 601 ++++++++++++++++++ src/components/chat/AgentWorkspace.test.tsx | 1 + src/components/chat/AgentWorkspace.tsx | 12 +- src/components/chat/GlobalChatbox.tsx | 2 + src/components/chat/GlobalChatbox.types.ts | 26 + .../chat/hooks/useAgentChatSession.test.tsx | 68 +- .../chat/hooks/useAgentChatSession.ts | 141 +++- src/lib/chatStream.test.ts | 69 +- src/lib/chatStream.ts | 91 ++- 9 files changed, 999 insertions(+), 12 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 03de6ce..ab13f01 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -9,6 +9,9 @@ import { Avatar, Box, Button, + Chip, + CircularProgress, + Collapse, IconButton, Paper, Stack, @@ -42,6 +45,15 @@ import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import StopRounded from "@mui/icons-material/StopRounded"; import SendRounded from "@mui/icons-material/SendRounded"; +import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; +import TerminalRounded from "@mui/icons-material/TerminalRounded"; +import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import BlockRounded from "@mui/icons-material/BlockRounded"; +import PushPinRounded from "@mui/icons-material/PushPinRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; +import type { PermissionReply } from "@/lib/chatStream"; type AgentTurnProps = { message: Message; @@ -55,6 +67,7 @@ type AgentTurnProps = { onRegenerate: () => void; onEditResubmit: (messageId: string, newContent: string) => void; onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; const MarkdownBlock = ({ children }: { children: string }) => ( @@ -63,6 +76,586 @@ const MarkdownBlock = ({ children }: { children: string }) => ( </div> ); +const formatMetadataValue = (value: unknown) => { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return "[unserializable]"; + } +}; + +const truncateText = (value: string, maxLength: number) => + value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; + +const formatMetadata = (metadata: Record<string, unknown>) => { + const entries = Object.entries(metadata) + .filter(([key]) => !["command", "path", "file", "directory"].includes(key)) + .slice(0, 3); + if (!entries.length) { + return ""; + } + return entries + .map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`) + .join(";"); +}; + +const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => { + if (permission.permission === "external_directory") return "访问工作区外目录"; + if (permission.permission === "bash") return "执行终端命令"; + if (permission.permission === "edit") return "修改文件内容"; + return permission.permission || "工具权限请求"; +}; + +const getPermissionPrimaryValue = ( + permission: NonNullable<Message["permissions"]>[number], +) => { + const command = permission.metadata.command; + if (typeof command === "string" && command.trim()) { + return command.trim(); + } + for (const key of ["path", "file", "directory"]) { + const value = permission.metadata[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return permission.patterns[0] ?? permission.permission; +}; + +const PermissionIcon = ({ + permission, +}: { + permission: NonNullable<Message["permissions"]>[number]; +}) => { + if (permission.permission === "bash") { + return <TerminalRounded sx={{ fontSize: 22 }} />; + } + if (permission.permission === "external_directory") { + return <FolderOpenRounded sx={{ fontSize: 22 }} />; + } + return <VerifiedUserRounded sx={{ fontSize: 22 }} />; +}; + +const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => { + if (status === "approved_always") return "已始终允许"; + if (status === "approved_once") return "已允许一次"; + if (status === "rejected") return "已拒绝"; + if (status === "error") return "提交失败"; + if (status === "submitting") return "提交中"; + return "等待确认"; +}; + +const pendingPermissionColor = "#f9a825"; + +const PermissionRequestCard = ({ + permission, + onReply, +}: { + permission: NonNullable<Message["permissions"]>[number]; + onReply: (requestId: string, reply: PermissionReply) => void; +}) => { + const theme = useTheme(); + const isPending = permission.status === "pending" || permission.status === "error"; + const isSubmitting = permission.status === "submitting"; + const primaryValue = getPermissionPrimaryValue(permission); + const metadataText = formatMetadata(permission.metadata); + const accentColor = + permission.status === "rejected" || permission.status === "error" + ? theme.palette.error.main + : permission.status === "pending" || permission.status === "submitting" + ? pendingPermissionColor + : theme.palette.success.main; + const statusLabel = getPermissionStatusLabel(permission.status); + const statusColor = + permission.status === "rejected" || permission.status === "error" + ? "error" + : permission.status === "pending" || permission.status === "submitting" + ? "warning" + : "success"; + + return ( + <Box + sx={{ + borderRadius: 3, + overflow: "hidden", + border: `1px solid ${alpha("#fff", 0.72)}`, + bgcolor: alpha("#fff", 0.5), + boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, + backdropFilter: "blur(20px)", + position: "relative", + "&::before": { + content: '""', + position: "absolute", + inset: "10px auto 10px 0", + width: 3, + borderRadius: "0 999px 999px 0", + bgcolor: accentColor, + }, + }} + > + <Stack + direction="row" + spacing={1} + alignItems="center" + sx={{ + px: 1.5, + py: 1.25, + pl: 1.75, + borderBottom: `1px solid ${alpha("#000", 0.05)}`, + }} + > + <Box + sx={{ + width: 32, + height: 32, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: accentColor, + bgcolor: alpha(accentColor, 0.1), + border: `1px solid ${alpha(accentColor, 0.16)}`, + }} + > + <PermissionIcon permission={permission} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + {getPermissionTitle(permission)} + </Typography> + </Box> + <Chip + size="small" + color={statusColor} + label={statusLabel} + sx={{ + height: 24, + fontSize: "0.7rem", + fontWeight: 800, + borderRadius: "12px", + bgcolor: + statusColor === "success" + ? alpha(theme.palette.success.main, 0.12) + : statusColor === "error" + ? alpha(theme.palette.error.main, 0.1) + : alpha(pendingPermissionColor, 0.14), + color: + statusColor === "success" + ? theme.palette.success.dark + : statusColor === "error" + ? theme.palette.error.main + : "#8a5a00", + "& .MuiChip-label": { px: 1 }, + }} + /> + </Stack> + + <Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}> + <Box + sx={{ + px: 1.25, + py: 1, + borderRadius: 2.5, + bgcolor: alpha("#000", 0.025), + border: `1px solid ${alpha("#000", 0.045)}`, + }} + > + <Typography variant="caption" color="text.secondary" fontWeight={800}> + 请求目标 + </Typography> + <Typography + variant="body2" + color="text.primary" + fontFamily={permission.permission === "bash" ? "monospace" : undefined} + sx={{ + mt: 0.25, + lineHeight: 1.55, + wordBreak: "break-word", + whiteSpace: "pre-wrap", + }} + > + {primaryValue} + </Typography> + </Box> + + {metadataText ? ( + <Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}> + {metadataText} + </Typography> + ) : null} + </Stack> + + {permission.error ? ( + <Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}> + <Typography + variant="caption" + color="error.main" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha(theme.palette.error.main, 0.06), + wordBreak: "break-word", + }} + > + {permission.error} + </Typography> + </Box> + ) : null} + + {isPending || isSubmitting ? ( + <Stack + direction="row" + spacing={1} + flexWrap="wrap" + useFlexGap + sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }} + > + <Button + size="small" + variant="contained" + disableElevation + disabled={isSubmitting} + onClick={() => onReply(permission.requestId, "once")} + startIcon={ + isSubmitting ? ( + <CircularProgress size={14} color="inherit" /> + ) : ( + <CheckCircleRounded fontSize="small" /> + ) + } + sx={{ + minWidth: 94, + height: 34, + borderRadius: "17px", + bgcolor: "#00838f", + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`, + "&:hover": { + bgcolor: "#006c78", + boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`, + }, + }} + > + 允许一次 + </Button> + <Button + size="small" + variant="outlined" + disabled={isSubmitting} + onClick={() => onReply(permission.requestId, "always")} + startIcon={<PushPinRounded fontSize="small" />} + sx={{ + height: 34, + borderRadius: "17px", + px: 1.5, + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + color: "#00838f", + borderColor: alpha("#00838f", 0.24), + bgcolor: alpha("#fff", 0.45), + "&:hover": { + borderColor: alpha("#00838f", 0.36), + bgcolor: alpha("#00838f", 0.08), + }, + }} + > + 始终允许 + </Button> + <Button + size="small" + color="error" + variant="outlined" + disabled={isSubmitting} + onClick={() => onReply(permission.requestId, "reject")} + startIcon={<BlockRounded fontSize="small" />} + sx={{ + height: 34, + borderRadius: "17px", + px: 1.5, + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + borderColor: alpha(theme.palette.error.main, 0.22), + bgcolor: alpha("#fff", 0.45), + "&:hover": { + borderColor: alpha(theme.palette.error.main, 0.34), + bgcolor: alpha(theme.palette.error.main, 0.07), + }, + }} + > + 拒绝 + </Button> + </Stack> + ) : null} + </Box> + ); +}; + +const PermissionRequestGroup = ({ + permissions, + onReply, +}: { + permissions: NonNullable<Message["permissions"]>; + onReply: (requestId: string, reply: PermissionReply) => void; +}) => { + const theme = useTheme(); + const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; + const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length; + const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length; + const pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount; + const hasPendingPermissions = pendingCount > 0; + const [expanded, setExpanded] = React.useState(false); + const latestPermissions = permissions.slice(-3); + const pendingPermissions = permissions.filter( + (permission) => + permission.status === "pending" || + permission.status === "submitting" || + permission.status === "error", + ); + const summaryItems = [ + { label: "共", value: permissions.length, color: theme.palette.text.secondary }, + { label: "允许一次", value: onceCount, color: "#00838f" }, + { label: "始终允许", value: alwaysCount, color: theme.palette.success.main }, + { label: "拒绝", value: rejectedCount, color: theme.palette.error.main }, + ]; + const chipColor = pendingCount > 0 ? pendingPermissionColor : rejectedCount > 0 ? theme.palette.error.main : theme.palette.success.main; + + return ( + <Box + sx={{ + borderRadius: 3, + overflow: "hidden", + border: `1px solid ${alpha("#fff", 0.72)}`, + bgcolor: alpha("#fff", 0.46), + boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`, + backdropFilter: "blur(20px)", + }} + > + <Stack + direction="row" + alignItems="center" + spacing={1} + role="button" + tabIndex={0} + onClick={() => setExpanded((value) => !value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded((value) => !value); + } + }} + sx={{ + px: 1.5, + py: 1.15, + cursor: "pointer", + transition: "background-color 0.2s ease", + "&:hover": { bgcolor: alpha("#000", 0.025) }, + }} + > + <Box + sx={{ + width: 30, + height: 30, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: chipColor, + bgcolor: alpha(chipColor, 0.1), + border: `1px solid ${alpha(chipColor, 0.15)}`, + }} + > + <VerifiedUserRounded sx={{ fontSize: 18 }} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + 权限请求 + </Typography> + <Stack + direction="row" + flexWrap="wrap" + gap={0.6} + sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }} + > + {summaryItems.map((item) => ( + <Box + key={item.label} + component="span" + sx={{ + display: "inline-flex", + alignItems: "center", + gap: 0.45, + height: 22, + px: 0.8, + borderRadius: "11px", + bgcolor: alpha(item.color, 0.08), + border: `1px solid ${alpha(item.color, 0.12)}`, + color: item.color, + fontSize: "0.7rem", + fontWeight: 800, + lineHeight: 1, + whiteSpace: "nowrap", + }} + > + <Box component="span" sx={{ color: alpha(item.color, 0.82), fontWeight: 700 }}> + {item.label} + </Box> + <Box component="span">{item.value} 项</Box> + </Box> + ))} + </Stack> + </Box> + <Chip + size="small" + label={`待确认 ${pendingCount} 项`} + sx={{ + height: 24, + borderRadius: "12px", + fontSize: "0.7rem", + fontWeight: 800, + color: chipColor, + bgcolor: alpha(chipColor, 0.1), + "& .MuiChip-label": { px: 1 }, + }} + /> + <IconButton + size="small" + aria-label={expanded ? "收起权限请求" : "展开权限请求"} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.035), + "&:hover": { bgcolor: alpha("#000", 0.07) }, + }} + > + {expanded ? ( + <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> + ) : ( + <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> + )} + </IconButton> + </Stack> + + {!expanded && !hasPendingPermissions && latestPermissions.length > 0 ? ( + <Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}> + {latestPermissions.map((permission, index) => { + const primaryValue = getPermissionPrimaryValue(permission); + const isLast = index === latestPermissions.length - 1; + const itemColor = + permission.status === "rejected" || permission.status === "error" + ? theme.palette.error.main + : permission.status === "approved_once" || permission.status === "approved_always" + ? theme.palette.success.main + : pendingPermissionColor; + + return ( + <Stack + key={permission.requestId} + direction="row" + spacing={1} + alignItems="center" + sx={{ + py: 0.8, + borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none", + borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`, + }} + > + <Box + sx={{ + width: 24, + height: 24, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: itemColor, + bgcolor: alpha(itemColor, 0.08), + }} + > + <PermissionIcon permission={permission} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}> + {getPermissionTitle(permission)} + </Typography> + <Typography + variant="caption" + color="text.secondary" + noWrap + sx={{ + display: "block", + fontFamily: permission.permission === "bash" ? "monospace" : undefined, + }} + > + {truncateText(primaryValue, 72)} + </Typography> + </Box> + <Chip + size="small" + label={getPermissionStatusLabel(permission.status)} + sx={{ + height: 22, + borderRadius: "11px", + fontSize: "0.68rem", + fontWeight: 800, + color: itemColor, + bgcolor: alpha(itemColor, 0.08), + "& .MuiChip-label": { px: 0.85 }, + }} + /> + </Stack> + ); + })} + </Stack> + ) : null} + + <AnimatePresence initial={false}> + {!expanded && hasPendingPermissions ? ( + <motion.div + key="pending-permissions" + initial={{ opacity: 0, y: -10, height: 0 }} + animate={{ opacity: 1, y: 0, height: "auto" }} + exit={{ opacity: 0, y: -8, height: 0 }} + transition={{ duration: 0.2, ease: "easeOut" }} + style={{ overflow: "hidden" }} + > + <Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}> + {pendingPermissions.map((permission) => ( + <PermissionRequestCard + key={permission.requestId} + permission={permission} + onReply={onReply} + /> + ))} + </Stack> + </motion.div> + ) : null} + </AnimatePresence> + + <Collapse in={expanded} timeout="auto" unmountOnExit> + <Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}> + {permissions.map((permission) => ( + <PermissionRequestCard + key={permission.requestId} + permission={permission} + onReply={onReply} + /> + ))} + </Stack> + </Collapse> + </Box> + ); +}; + export const AgentTurn = React.memo( ({ message, @@ -76,6 +669,7 @@ export const AgentTurn = React.memo( onRegenerate, onEditResubmit, onCycleBranch, + onReplyPermission, }: AgentTurnProps) => { const theme = useTheme(); const isUser = message.role === "user"; @@ -359,6 +953,13 @@ export const AgentTurn = React.memo( <AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} /> ) : null} + {message.permissions?.length ? ( + <PermissionRequestGroup + permissions={message.permissions} + onReply={onReplyPermission} + /> + ) : null} + <Box sx={{ p: 1.5, diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index 04b4c4f..9efa895 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -46,6 +46,7 @@ describe("AgentWorkspace", () => { onRegenerate: jest.fn(), onEditResubmit: jest.fn(), onCycleBranch: jest.fn(), + onReplyPermission: jest.fn(), }; beforeEach(() => { diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 5f3e03f..ba15fa8 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -11,6 +11,7 @@ import MapRounded from "@mui/icons-material/MapRounded"; import { AgentTurn } from "./AgentTurn"; import { TypingIndicator } from "./GlobalChatbox.parts"; +import type { PermissionReply } from "@/lib/chatStream"; import type { BranchGroup, BranchState, @@ -35,6 +36,7 @@ type AgentWorkspaceProps = { onRegenerate: () => void; onEditResubmit: (messageId: string, newContent: string) => void; onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; type TurnListProps = { @@ -50,6 +52,7 @@ type TurnListProps = { onRegenerate: () => void; onEditResubmit: (messageId: string, newContent: string) => void; onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; const sameMessages = (left: Message[], right: Message[]) => @@ -69,6 +72,7 @@ const TurnListInner = ({ onRegenerate, onEditResubmit, onCycleBranch, + onReplyPermission, }: TurnListProps) => { const branchStateByRootId = React.useMemo(() => { const next = new Map<string, BranchState>(); @@ -101,6 +105,7 @@ const TurnListInner = ({ onRegenerate={onRegenerate} onEditResubmit={onEditResubmit} onCycleBranch={onCycleBranch} + onReplyPermission={onReplyPermission} /> ); })} @@ -122,7 +127,8 @@ const TurnList = React.memo( prevProps.isTtsSupported === nextProps.isTtsSupported && prevProps.onRegenerate === nextProps.onRegenerate && prevProps.onEditResubmit === nextProps.onEditResubmit && - prevProps.onCycleBranch === nextProps.onCycleBranch, + prevProps.onCycleBranch === nextProps.onCycleBranch && + prevProps.onReplyPermission === nextProps.onReplyPermission, ); TurnList.displayName = "TurnList"; @@ -257,6 +263,7 @@ export const AgentWorkspace = ({ onRegenerate, onEditResubmit, onCycleBranch, + onReplyPermission, }: AgentWorkspaceProps) => { const theme = useTheme(); const latestAssistant = [...messages] @@ -311,6 +318,7 @@ export const AgentWorkspace = ({ onRegenerate={onRegenerate} onEditResubmit={onEditResubmit} onCycleBranch={onCycleBranch} + onReplyPermission={onReplyPermission} /> {streamingMessage ? ( @@ -327,6 +335,7 @@ export const AgentWorkspace = ({ onRegenerate={onRegenerate} onEditResubmit={onEditResubmit} onCycleBranch={onCycleBranch} + onReplyPermission={onReplyPermission} /> ) : null} @@ -353,6 +362,7 @@ export const AgentWorkspace = ({ onRegenerate={onRegenerate} onEditResubmit={onEditResubmit} onCycleBranch={onCycleBranch} + onReplyPermission={onReplyPermission} /> </motion.div> </AnimatePresence> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index eb81823..a020a5f 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -75,6 +75,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { editAndResubmit, cycleBranch, abort, + replyPermission, createSession, renameSession, removeSession, @@ -354,6 +355,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onRegenerate={regenerate} onEditResubmit={editAndResubmit} onCycleBranch={cycleBranch} + onReplyPermission={replyPermission} /> <AgentComposer diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 8a580b5..8e29200 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -22,6 +22,31 @@ export type AgentArtifact = { params: Record<string, unknown>; }; +export type AgentPermissionStatus = + | "pending" + | "submitting" + | "approved_once" + | "approved_always" + | "rejected" + | "error"; + +export type AgentPermissionRequest = { + requestId: string; + sessionId: string; + permission: string; + patterns: string[]; + metadata: Record<string, unknown>; + always: string[]; + tool?: { + messageID: string; + callID: string; + }; + createdAt: number; + repliedAt?: number; + status: AgentPermissionStatus; + error?: string; +}; + export type Message = { id: string; role: "user" | "assistant"; @@ -29,6 +54,7 @@ export type Message = { isError?: boolean; progress?: ChatProgress[]; artifacts?: AgentArtifact[]; + permissions?: AgentPermissionRequest[]; branchRootId?: string; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 8e3d84d..d02735f 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -3,12 +3,18 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; -import { abortAgentChat, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; +import { + abortAgentChat, + replyAgentPermission, + resumeAgentChatStream, + streamAgentChat, +} from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), forkAgentChat: jest.fn(async () => "forked-session"), + replyAgentPermission: jest.fn(async () => undefined), resumeAgentChatStream: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined), })); @@ -46,9 +52,11 @@ describe("useAgentChatSession", () => { saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); + jest.mocked(replyAgentPermission).mockReset(); jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); deleteChatSession.mockImplementation(async () => undefined); @@ -353,6 +361,62 @@ describe("useAgentChatSession", () => { expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); }); + it("tracks permission requests and submits replies", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise<void>(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + void result.current.sendPrompt("删除临时文件"); + await Promise.resolve(); + }); + + act(() => { + emitStreamEvent?.({ + type: "permission_request", + sessionId: "session-1", + requestId: "perm-1", + permission: "bash", + patterns: ["rm *"], + metadata: { command: "rm tmp.txt" }, + always: ["rm *"], + createdAt: 123, + }); + }); + + expect(result.current.messages.at(-1)?.permissions).toEqual([ + expect.objectContaining({ + requestId: "perm-1", + sessionId: "session-1", + status: "pending", + }), + ]); + + await act(async () => { + await result.current.replyPermission("perm-1", "once"); + }); + + expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once"); + expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual( + expect.objectContaining({ + requestId: "perm-1", + status: "approved_once", + }), + ); + }); + it("finalizes running progress when aborting an active prompt", async () => { listChatSessions.mockResolvedValue([]); jest.mocked(streamAgentChat).mockImplementationOnce( @@ -368,7 +432,7 @@ describe("useAgentChatSession", () => { startedAt: 1000, } satisfies StreamEvent); - signal.addEventListener("abort", () => { + signal?.addEventListener("abort", () => { reject(new Error("aborted")); }); }), diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 7694f6e..31e814a 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -5,12 +5,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { abortAgentChat, forkAgentChat, + replyAgentPermission, resumeAgentChatStream, streamAgentChat, } from "@/lib/chatStream"; -import type { AgentModel, StreamEvent } from "@/lib/chatStream"; +import type { AgentModel, PermissionReply, StreamEvent } from "@/lib/chatStream"; import type { AgentArtifact, + AgentPermissionRequest, BranchGroup, BranchTransition, ChatProgress, @@ -130,6 +132,41 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) => }; }); +const upsertPermission = ( + permissions: AgentPermissionRequest[] | undefined, + event: StreamEvent & { type: "permission_request" }, +) => { + const next = [...(permissions ?? [])]; + const index = next.findIndex((item) => item.requestId === event.requestId); + const nextItem: AgentPermissionRequest = { + requestId: event.requestId, + sessionId: event.sessionId, + permission: event.permission, + patterns: event.patterns, + metadata: event.metadata, + always: event.always, + tool: event.tool, + createdAt: event.createdAt, + status: "pending", + }; + if (index >= 0) { + next[index] = { + ...next[index], + ...nextItem, + status: next[index].status === "submitting" ? "submitting" : nextItem.status, + }; + } else { + next.push(nextItem); + } + return next; +}; + +const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => { + if (reply === "always") return "approved_always"; + if (reply === "once") return "approved_once"; + return "rejected"; +}; + const finalizeAssistantMessageAfterAbort = (message: Message): Message => { const completedProgress = completeRunningProgress(message.progress); const hasVisibleOutput = @@ -442,6 +479,38 @@ export const useAgentChatSession = ({ assistantMessageId, appendArtifact, }); + } else if (event.type === "permission_request") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + permissions: upsertPermission(message.permissions, event), + } + : message, + ), + ); + } else if (event.type === "permission_response") { + setMessages((prev) => + prev.map((message) => { + if (message.id !== assistantMessageId || !message.permissions?.length) { + return message; + } + return { + ...message, + permissions: message.permissions.map((permission) => + permission.requestId === event.requestId + ? { + ...permission, + status: toPermissionStatus(event.reply), + repliedAt: Date.now(), + error: undefined, + } + : permission, + ), + }; + }), + ); } else if (event.type === "session_title") { const nextTitle = event.title.trim(); if (nextTitle && !isSessionTitleManuallyEditedRef.current) { @@ -647,6 +716,75 @@ export const useAgentChatSession = ({ cancelPromiseRef.current = trackedCancelPromise; }, [getLastAssistantMessageId]); + const replyPermission = useCallback( + async (requestId: string, reply: PermissionReply) => { + const target = messagesRef.current + .flatMap((message) => message.permissions ?? []) + .find((permission) => permission.requestId === requestId); + if (!target || target.status === "submitting") { + return; + } + + setMessages((prev) => + prev.map((message) => + !message.permissions?.some((permission) => permission.requestId === requestId) + ? message + : { + ...message, + permissions: message.permissions.map((permission) => + permission.requestId === requestId + ? { ...permission, status: "submitting", error: undefined } + : permission, + ), + }, + ), + ); + + try { + await replyAgentPermission(target.sessionId, requestId, reply); + setMessages((prev) => + prev.map((message) => + !message.permissions?.some((permission) => permission.requestId === requestId) + ? message + : { + ...message, + permissions: message.permissions.map((permission) => + permission.requestId === requestId + ? { + ...permission, + status: toPermissionStatus(reply), + repliedAt: Date.now(), + error: undefined, + } + : permission, + ), + }, + ), + ); + } catch (error) { + setMessages((prev) => + prev.map((message) => + !message.permissions?.some((permission) => permission.requestId === requestId) + ? message + : { + ...message, + permissions: message.permissions.map((permission) => + permission.requestId === requestId + ? { + ...permission, + status: "error", + error: error instanceof Error ? error.message : String(error), + } + : permission, + ), + }, + ), + ); + } + }, + [], + ); + const createSession = useCallback(() => { if (isHydrating || isStreaming) return; @@ -982,6 +1120,7 @@ export const useAgentChatSession = ({ editAndResubmit, cycleBranch, abort, + replyPermission, createSession, renameSession, removeSession, diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 64a6c62..6be9065 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -1,6 +1,8 @@ import { abortAgentChat, forkAgentChat, + replyAgentPermission, + type StreamEvent, resumeAgentChatStream, streamAgentChat, } from "./chatStream"; @@ -162,12 +164,7 @@ describe("streamAgentChat", () => { ]), }); - const events: Array<{ - type: string; - sessionId?: string; - tool?: string; - params?: Record<string, unknown>; - }> = []; + const events: StreamEvent[] = []; await streamAgentChat({ message: "hi", @@ -182,6 +179,43 @@ describe("streamAgentChat", () => { }); }); + it("parses permission request and response events", async () => { + apiFetch.mockResolvedValue({ + ok: true, + body: makeStream([ + 'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"metadata":{"command":"rm tmp.txt"},"always":["rm *"],"created_at":123}\n\n', + 'event: permission_response\ndata: {"session_id":"s1","request_id":"perm-1","reply":"reject"}\n\n', + ]), + }); + + const events: StreamEvent[] = []; + + await streamAgentChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([ + { + type: "permission_request", + sessionId: "s1", + requestId: "perm-1", + permission: "bash", + patterns: ["rm *"], + metadata: { command: "rm tmp.txt" }, + always: ["rm *"], + tool: undefined, + createdAt: 123, + }, + { + type: "permission_response", + sessionId: "s1", + requestId: "perm-1", + reply: "reject", + }, + ]); + }); + it("emits error when response is not ok", async () => { apiFetch.mockResolvedValue({ ok: false, @@ -255,6 +289,29 @@ describe("streamAgentChat", () => { ); }); + it("calls permission reply endpoint", async () => { + apiFetch.mockResolvedValue({ + ok: true, + status: 202, + text: async () => "", + }); + + await replyAgentPermission("s1", "perm-1", "once"); + + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"), + expect.objectContaining({ + method: "POST", + projectHeaderMode: "include", + skipAuthRedirect: true, + body: JSON.stringify({ + session_id: "s1", + reply: "once", + }), + }), + ); + }); + it("calls fork endpoint and returns new session id", async () => { apiFetch.mockResolvedValue({ ok: true, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index a6581e5..5bbdc35 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -5,6 +5,8 @@ export type AgentModel = | "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-pro"; +export type PermissionReply = "once" | "always" | "reject"; + export type StreamEvent = | { type: "state"; @@ -41,6 +43,26 @@ export type StreamEvent = sessionId: string; tool: string; params: Record<string, unknown>; + } + | { + type: "permission_request"; + sessionId: string; + requestId: string; + permission: string; + patterns: string[]; + metadata: Record<string, unknown>; + always: string[]; + tool?: { + messageID: string; + callID: string; + }; + createdAt: number; + } + | { + type: "permission_response"; + sessionId: string; + requestId: string; + reply: PermissionReply; }; type StreamOptions = { @@ -111,7 +133,7 @@ const emitParsedStreamEvent = ( content?: string; message?: string; detail?: string; - tool?: string; + tool?: unknown; params?: Record<string, unknown>; arguments?: unknown; id?: string; @@ -126,6 +148,13 @@ const emitParsedStreamEvent = ( elapsed_ms?: number; duration_ms?: number; total_duration_ms?: number; + request_id?: string; + permission?: string; + patterns?: unknown; + metadata?: unknown; + always?: unknown; + created_at?: number; + reply?: PermissionReply; }; if (event === "state") { onEvent({ @@ -179,9 +208,39 @@ const emitParsedStreamEvent = ( onEvent({ type: "tool_call", sessionId: parsed.session_id ?? "", - tool: parsed.tool ?? "", + tool: typeof parsed.tool === "string" ? parsed.tool : "", params: resolveToolParams(parsed.params, parsed.arguments), }); + } else if (event === "permission_request") { + onEvent({ + type: "permission_request", + sessionId: parsed.session_id ?? "", + requestId: parsed.request_id ?? "", + permission: parsed.permission ?? "", + patterns: Array.isArray(parsed.patterns) + ? parsed.patterns.filter((item): item is string => typeof item === "string") + : [], + metadata: isObjectRecord(parsed.metadata) ? parsed.metadata : {}, + always: Array.isArray(parsed.always) + ? parsed.always.filter((item): item is string => typeof item === "string") + : [], + tool: isObjectRecord(parsed.tool) && + typeof parsed.tool.messageID === "string" && + typeof parsed.tool.callID === "string" + ? { + messageID: parsed.tool.messageID, + callID: parsed.tool.callID, + } + : undefined, + createdAt: parsed.created_at ?? Date.now(), + }); + } else if (event === "permission_response") { + onEvent({ + type: "permission_response", + sessionId: parsed.session_id ?? "", + requestId: parsed.request_id ?? "", + reply: parsed.reply ?? "reject", + }); } } catch { onEvent({ @@ -349,6 +408,34 @@ export const abortAgentChat = async (sessionId?: string) => { } }; +export const replyAgentPermission = async ( + sessionId: string, + requestId: string, + reply: PermissionReply, +) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: sessionId, + reply, + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `permission reply failed: ${response.status}`); + } +}; + export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, { method: "POST", -- 2.54.0 From d31565d52cdb14039f9d883ab38bceb9d17d6908 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 13:44:23 +0800 Subject: [PATCH 163/281] =?UTF-8?q?fix(chat):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=9D=83=E9=99=90=E8=AF=B7=E6=B1=82=E6=8A=98=E5=8F=A0=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentTurn.tsx | 121 +++++++++++++++++------------- 1 file changed, 68 insertions(+), 53 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index ab13f01..9b53bc1 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -20,6 +20,7 @@ import { alpha, useTheme, } from "@mui/material"; +import type { Theme } from "@mui/material/styles"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import RefreshRounded from "@mui/icons-material/RefreshRounded"; import EditRounded from "@mui/icons-material/EditRounded"; @@ -149,6 +150,27 @@ const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[nu }; const pendingPermissionColor = "#f9a825"; +const approvedOncePermissionColor = "#00838f"; + +const getPermissionStatusColor = ( + status: NonNullable<Message["permissions"]>[number]["status"], + theme: Theme, +) => { + if (status === "approved_once") return approvedOncePermissionColor; + if (status === "approved_always") return theme.palette.success.main; + if (status === "rejected" || status === "error") return theme.palette.error.main; + return pendingPermissionColor; +}; + +const getPermissionStatusTextColor = ( + status: NonNullable<Message["permissions"]>[number]["status"], + theme: Theme, +) => { + if (status === "approved_once") return "#006c78"; + if (status === "approved_always") return theme.palette.success.dark; + if (status === "rejected" || status === "error") return theme.palette.error.main; + return "#8a5a00"; +}; const PermissionRequestCard = ({ permission, @@ -162,19 +184,9 @@ const PermissionRequestCard = ({ const isSubmitting = permission.status === "submitting"; const primaryValue = getPermissionPrimaryValue(permission); const metadataText = formatMetadata(permission.metadata); - const accentColor = - permission.status === "rejected" || permission.status === "error" - ? theme.palette.error.main - : permission.status === "pending" || permission.status === "submitting" - ? pendingPermissionColor - : theme.palette.success.main; + const accentColor = getPermissionStatusColor(permission.status, theme); + const statusTextColor = getPermissionStatusTextColor(permission.status, theme); const statusLabel = getPermissionStatusLabel(permission.status); - const statusColor = - permission.status === "rejected" || permission.status === "error" - ? "error" - : permission.status === "pending" || permission.status === "submitting" - ? "warning" - : "success"; return ( <Box @@ -229,25 +241,14 @@ const PermissionRequestCard = ({ </Box> <Chip size="small" - color={statusColor} label={statusLabel} sx={{ height: 24, fontSize: "0.7rem", fontWeight: 800, borderRadius: "12px", - bgcolor: - statusColor === "success" - ? alpha(theme.palette.success.main, 0.12) - : statusColor === "error" - ? alpha(theme.palette.error.main, 0.1) - : alpha(pendingPermissionColor, 0.14), - color: - statusColor === "success" - ? theme.palette.success.dark - : statusColor === "error" - ? theme.palette.error.main - : "#8a5a00", + bgcolor: alpha(accentColor, 0.12), + color: statusTextColor, "& .MuiChip-label": { px: 1 }, }} /> @@ -401,9 +402,11 @@ const PermissionRequestCard = ({ const PermissionRequestGroup = ({ permissions, + isRunning, onReply, }: { permissions: NonNullable<Message["permissions"]>; + isRunning: boolean; onReply: (requestId: string, reply: PermissionReply) => void; }) => { const theme = useTheme(); @@ -422,11 +425,12 @@ const PermissionRequestGroup = ({ ); const summaryItems = [ { label: "共", value: permissions.length, color: theme.palette.text.secondary }, - { label: "允许一次", value: onceCount, color: "#00838f" }, - { label: "始终允许", value: alwaysCount, color: theme.palette.success.main }, - { label: "拒绝", value: rejectedCount, color: theme.palette.error.main }, + { label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, + { label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, + { label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) }, ]; - const chipColor = pendingCount > 0 ? pendingPermissionColor : rejectedCount > 0 ? theme.palette.error.main : theme.palette.success.main; + const chipColor = pendingCount > 0 ? getPermissionStatusColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusColor("rejected", theme) : getPermissionStatusColor("approved_always", theme); + const chipTextColor = pendingCount > 0 ? getPermissionStatusTextColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusTextColor("rejected", theme) : getPermissionStatusTextColor("approved_always", theme); return ( <Box @@ -498,14 +502,20 @@ const PermissionRequestGroup = ({ borderRadius: "11px", bgcolor: alpha(item.color, 0.08), border: `1px solid ${alpha(item.color, 0.12)}`, - color: item.color, + color: "textColor" in item ? item.textColor : item.color, fontSize: "0.7rem", fontWeight: 800, lineHeight: 1, whiteSpace: "nowrap", }} > - <Box component="span" sx={{ color: alpha(item.color, 0.82), fontWeight: 700 }}> + <Box + component="span" + sx={{ + color: "textColor" in item ? item.textColor : item.color, + fontWeight: 700, + }} + > {item.label} </Box> <Box component="span">{item.value} 项</Box> @@ -513,19 +523,21 @@ const PermissionRequestGroup = ({ ))} </Stack> </Box> - <Chip - size="small" - label={`待确认 ${pendingCount} 项`} - sx={{ - height: 24, - borderRadius: "12px", - fontSize: "0.7rem", - fontWeight: 800, - color: chipColor, - bgcolor: alpha(chipColor, 0.1), - "& .MuiChip-label": { px: 1 }, - }} - /> + {isRunning && pendingCount > 0 ? ( + <Chip + size="small" + label={`待确认 ${pendingCount} 项`} + sx={{ + height: 24, + borderRadius: "12px", + fontSize: "0.7rem", + fontWeight: 800, + color: chipTextColor, + bgcolor: alpha(chipColor, 0.1), + "& .MuiChip-label": { px: 1 }, + }} + /> + ) : null} <IconButton size="small" aria-label={expanded ? "收起权限请求" : "展开权限请求"} @@ -545,17 +557,13 @@ const PermissionRequestGroup = ({ </IconButton> </Stack> - {!expanded && !hasPendingPermissions && latestPermissions.length > 0 ? ( + {!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? ( <Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}> {latestPermissions.map((permission, index) => { const primaryValue = getPermissionPrimaryValue(permission); const isLast = index === latestPermissions.length - 1; - const itemColor = - permission.status === "rejected" || permission.status === "error" - ? theme.palette.error.main - : permission.status === "approved_once" || permission.status === "approved_always" - ? theme.palette.success.main - : pendingPermissionColor; + const itemColor = getPermissionStatusColor(permission.status, theme); + const itemTextColor = getPermissionStatusTextColor(permission.status, theme); return ( <Stack @@ -607,7 +615,7 @@ const PermissionRequestGroup = ({ borderRadius: "11px", fontSize: "0.68rem", fontWeight: 800, - color: itemColor, + color: itemTextColor, bgcolor: alpha(itemColor, 0.08), "& .MuiChip-label": { px: 0.85 }, }} @@ -619,7 +627,7 @@ const PermissionRequestGroup = ({ ) : null} <AnimatePresence initial={false}> - {!expanded && hasPendingPermissions ? ( + {!expanded && isRunning && hasPendingPermissions ? ( <motion.div key="pending-permissions" initial={{ opacity: 0, y: -10, height: 0 }} @@ -678,6 +686,12 @@ export const AgentTurn = React.memo( const [isEditing, setIsEditing] = React.useState(false); const [editDraft, setEditDraft] = React.useState(message.content); const rootMessageId = message.branchRootId ?? message.id; + const isProgressComplete = message.progress?.some( + (item) => item.phase === "complete" && item.status === "completed", + ) ?? false; + const isProgressRunning = !isErrorMessage && !isProgressComplete && ( + message.progress?.some((item) => item.status === "running") ?? false + ); const parsedAssistantSections = useMemo( () => @@ -956,6 +970,7 @@ export const AgentTurn = React.memo( {message.permissions?.length ? ( <PermissionRequestGroup permissions={message.permissions} + isRunning={isProgressRunning} onReply={onReplyPermission} /> ) : null} -- 2.54.0 From f7cd5ebfa79a55e48bfa5dbc9a4d893c188aecea Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 14:14:52 +0800 Subject: [PATCH 164/281] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=89=B9=E5=87=86=E6=A8=A1=E5=BC=8F=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentComposer.tsx | 98 ++++++++++++++++++- src/components/chat/GlobalChatbox.tsx | 7 +- .../chat/hooks/useAgentChatSession.ts | 20 +++- src/lib/chatStream.test.ts | 1 + src/lib/chatStream.ts | 4 + 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index f9cad22..5400c7a 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -26,7 +26,9 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; import BoltRounded from "@mui/icons-material/BoltRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; -import type { AgentModel } from "@/lib/chatStream"; +import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; +import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded"; +import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; export type AgentComposerHandle = { focus: () => void; @@ -48,6 +50,8 @@ type AgentComposerProps = { onStopListening: () => void; selectedModel: AgentModel; onModelChange: (model: AgentModel) => void; + approvalMode: AgentApprovalMode; + onApprovalModeChange: (mode: AgentApprovalMode) => void; }; export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ @@ -62,6 +66,8 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer onStopListening, selectedModel, onModelChange, + approvalMode, + onApprovalModeChange, }, ref) { const theme = useTheme(); const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); @@ -245,6 +251,96 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer </IconButton> ) ) : null} + <FormControl size="small" sx={{ minWidth: 102 }}> + <Select + value={approvalMode} + onChange={(event) => + onApprovalModeChange(event.target.value as AgentApprovalMode) + } + disabled={isHydrating || isStreaming} + aria-label="权限批准模式" + renderValue={(val) => ( + <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> + {val === "always" ? ( + <AdminPanelSettingsRounded sx={{ fontSize: 16, color: "inherit" }} /> + ) : ( + <VerifiedUserRounded sx={{ fontSize: 16, color: "inherit" }} /> + )} + <Typography sx={{ fontSize: "0.78rem", fontWeight: 700, color: "inherit" }}> + {val === "always" ? "始终允许" : "请求批准"} + </Typography> + </Box> + )} + MenuProps={{ + anchorOrigin: { vertical: "top", horizontal: "left" }, + transformOrigin: { vertical: "bottom", horizontal: "left" }, + sx: { zIndex: (theme) => theme.zIndex.modal + 110 }, + PaperProps: { + sx: { + mb: 1.5, + width: 210, + borderRadius: 4, + bgcolor: alpha("#fff", 0.9), + backdropFilter: "blur(24px)", + border: `1px solid ${alpha("#fff", 0.9)}`, + boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}`, + "& .MuiList-root": { p: 1 }, + "& .MuiMenuItem-root": { + px: 1.5, + py: 1.2, + mb: 0.5, + borderRadius: 3, + alignItems: "flex-start", + "&:last-child": { mb: 0 }, + "&.Mui-selected": { + bgcolor: alpha("#00acc1", 0.08), + "&:hover": { bgcolor: alpha("#00acc1", 0.12) }, + "& .title": { color: "#00838f" }, + "& .icon": { color: "#00acc1" }, + }, + }, + }, + }, + }} + sx={{ + height: 36, + borderRadius: "18px", + bgcolor: alpha("#fff", 0.6), + color: "text.secondary", + ".MuiOutlinedInput-notchedOutline": { border: "none" }, + ".MuiSelect-select": { + py: 0, + pl: 1, + pr: "28px !important", + display: "flex", + alignItems: "center", + }, + "&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": { + bgcolor: alpha("#000", 0.06), + color: "text.primary", + }, + ".MuiSelect-icon": { + color: "text.secondary", + right: 4, + }, + }} + > + <MenuItem value="request"> + <VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary" }} /> + <Box> + <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>请求批准</Typography> + <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>工具权限逐次确认</Typography> + </Box> + </MenuItem> + <MenuItem value="always"> + <AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary" }} /> + <Box> + <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>始终允许</Typography> + <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>自动允许本轮权限请求</Typography> + </Box> + </MenuItem> + </Select> + </FormControl> </Stack> <Stack direction="row" spacing={1} alignItems="center"> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index a020a5f..a9d674c 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material"; import { useNotification } from "@refinedev/core"; import { getAccessToken } from "@/lib/authToken"; -import type { AgentModel } from "@/lib/chatStream"; +import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import { useProjectStore } from "@/store/projectStore"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; @@ -31,6 +31,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [selectedModel, setSelectedModel] = useState<AgentModel>( "deepseek/deepseek-v4-pro", ); + const [approvalMode, setApprovalMode] = + useState<AgentApprovalMode>("request"); const bottomRef = useRef<HTMLDivElement>(null); const composerRef = useRef<AgentComposerHandle | null>(null); @@ -85,6 +87,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onToolCall: handleToolCall, onBeforeSend: stopListening, getModel: () => selectedModel, + getApprovalMode: () => approvalMode, }); const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { @@ -371,6 +374,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onStopListening={stopListening} selectedModel={selectedModel} onModelChange={setSelectedModel} + approvalMode={approvalMode} + onApprovalModeChange={setApprovalMode} /> </Box> </Box> diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 31e814a..8ea558a 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -9,7 +9,12 @@ import { resumeAgentChatStream, streamAgentChat, } from "@/lib/chatStream"; -import type { AgentModel, PermissionReply, StreamEvent } from "@/lib/chatStream"; +import type { + AgentApprovalMode, + AgentModel, + PermissionReply, + StreamEvent, +} from "@/lib/chatStream"; import type { AgentArtifact, AgentPermissionRequest, @@ -45,6 +50,7 @@ type UseAgentChatSessionOptions = { ) => void; onBeforeSend?: () => void; getModel?: () => AgentModel; + getApprovalMode?: () => AgentApprovalMode; }; type PromptRunOptions = { @@ -210,6 +216,7 @@ export const useAgentChatSession = ({ onToolCall, onBeforeSend, getModel, + getApprovalMode, }: UseAgentChatSessionOptions) => { const hydrationCompletedRef = useRef(false); const hydrationNonceRef = useRef(0); @@ -636,6 +643,7 @@ export const useAgentChatSession = ({ message: prompt, sessionId: sessionIdOverride ?? sessionIdRef.current, model: getModel?.(), + approvalMode: getApprovalMode?.(), signal: controller.signal, onEvent: (event) => applyStreamEvent(event, { @@ -686,7 +694,15 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [applyStreamEvent, getModel, isHydrating, isStreaming, messages, onBeforeSend], + [ + applyStreamEvent, + getApprovalMode, + getModel, + isHydrating, + isStreaming, + messages, + onBeforeSend, + ], ); const abort = useCallback(() => { diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 6be9065..bda5744 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -72,6 +72,7 @@ describe("streamAgentChat", () => { message: "hi", session_id: undefined, model: "deepseek/deepseek-v4-pro", + approval_mode: undefined, }), }), ); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 5bbdc35..7b4a2e9 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -6,6 +6,7 @@ export type AgentModel = | "deepseek/deepseek-v4-pro"; export type PermissionReply = "once" | "always" | "reject"; +export type AgentApprovalMode = "request" | "always"; export type StreamEvent = | { @@ -69,6 +70,7 @@ type StreamOptions = { message: string; sessionId?: string; model?: AgentModel; + approvalMode?: AgentApprovalMode; signal?: AbortSignal; onEvent: (event: StreamEvent) => void; }; @@ -283,6 +285,7 @@ export const streamAgentChat = async ({ message, sessionId, model, + approvalMode, signal, onEvent, }: StreamOptions) => { @@ -301,6 +304,7 @@ export const streamAgentChat = async ({ message, session_id: sessionId, model, + approval_mode: approvalMode, }), projectHeaderMode: "include", userHeaderMode: "include", -- 2.54.0 From 40cc355fff06d88278f1549a751e6a5976f9c124 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 14:38:52 +0800 Subject: [PATCH 165/281] =?UTF-8?q?fix(chat):=20=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=89=8D=E6=92=A4=E9=94=80=E6=97=A7=E6=B6=88?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentComposer.tsx | 15 +++++----- .../chat/hooks/useAgentChatSession.test.tsx | 29 +++++++++++++++++++ .../chat/hooks/useAgentChatSession.ts | 4 +++ src/lib/chatStream.test.ts | 1 + src/lib/chatStream.ts | 3 ++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index 5400c7a..40d4c33 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -251,7 +251,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer </IconButton> ) ) : null} - <FormControl size="small" sx={{ minWidth: 102 }}> + <FormControl size="small" sx={{ minWidth: 96 }}> <Select value={approvalMode} onChange={(event) => @@ -260,13 +260,13 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer disabled={isHydrating || isStreaming} aria-label="权限批准模式" renderValue={(val) => ( - <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> + <Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}> {val === "always" ? ( - <AdminPanelSettingsRounded sx={{ fontSize: 16, color: "inherit" }} /> + <AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} /> ) : ( - <VerifiedUserRounded sx={{ fontSize: 16, color: "inherit" }} /> + <VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} /> )} - <Typography sx={{ fontSize: "0.78rem", fontWeight: 700, color: "inherit" }}> + <Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}> {val === "always" ? "始终允许" : "请求批准"} </Typography> </Box> @@ -312,6 +312,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer py: 0, pl: 1, pr: "28px !important", + minHeight: 36, display: "flex", alignItems: "center", }, @@ -326,14 +327,14 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer }} > <MenuItem value="request"> - <VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary" }} /> + <VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} /> <Box> <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>请求批准</Typography> <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>工具权限逐次确认</Typography> </Box> </MenuItem> <MenuItem value="always"> - <AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary" }} /> + <AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} /> <Box> <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>始终允许</Typography> <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>自动允许本轮权限请求</Typography> diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index d02735f..d7322b5 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -521,4 +521,33 @@ describe("useAgentChatSession", () => { expect.anything(), ); }); + + it("asks the backend to undo the previous user turn before regenerating", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("重新分析压力异常"); + }); + + await act(async () => { + await result.current.regenerate(); + }); + + expect(streamAgentChat).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: "重新分析压力异常", + regenerateFromMessageIndex: 0, + }), + ); + }); }); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 8ea558a..f9145d2 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -56,6 +56,7 @@ type UseAgentChatSessionOptions = { type PromptRunOptions = { prompt: string; sessionIdOverride?: string; + regenerateFromMessageIndex?: number; preparedMessages?: Message[]; userMessage?: Message; assistantMessage?: Message; @@ -609,6 +610,7 @@ export const useAgentChatSession = ({ async ({ prompt: rawPrompt, sessionIdOverride, + regenerateFromMessageIndex, preparedMessages, userMessage, assistantMessage, @@ -644,6 +646,7 @@ export const useAgentChatSession = ({ sessionId: sessionIdOverride ?? sessionIdRef.current, model: getModel?.(), approvalMode: getApprovalMode?.(), + regenerateFromMessageIndex, signal: controller.signal, onEvent: (event) => applyStreamEvent(event, { @@ -991,6 +994,7 @@ export const useAgentChatSession = ({ setMessages(nextMessages); await runPrompt({ prompt: lastUserContent, + regenerateFromMessageIndex: lastUserIndex, preparedMessages: [ ...nextMessages, nextUserMessage, diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index bda5744..5477c02 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -73,6 +73,7 @@ describe("streamAgentChat", () => { session_id: undefined, model: "deepseek/deepseek-v4-pro", approval_mode: undefined, + regenerate_from_message_index: undefined, }), }), ); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 7b4a2e9..88f6948 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -71,6 +71,7 @@ type StreamOptions = { sessionId?: string; model?: AgentModel; approvalMode?: AgentApprovalMode; + regenerateFromMessageIndex?: number; signal?: AbortSignal; onEvent: (event: StreamEvent) => void; }; @@ -286,6 +287,7 @@ export const streamAgentChat = async ({ sessionId, model, approvalMode, + regenerateFromMessageIndex, signal, onEvent, }: StreamOptions) => { @@ -305,6 +307,7 @@ export const streamAgentChat = async ({ session_id: sessionId, model, approval_mode: approvalMode, + regenerate_from_message_index: regenerateFromMessageIndex, }), projectHeaderMode: "include", userHeaderMode: "include", -- 2.54.0 From 34fd5bfb1aa8ed1b72121c47489e1eea9d02a0dd Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 15:13:21 +0800 Subject: [PATCH 166/281] fix(chat): guard generated title events --- .../chat/hooks/useAgentChatSession.test.tsx | 59 +++++++++++++++++ .../chat/hooks/useAgentChatSession.ts | 65 +++++++++++++------ 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index d7322b5..f7eb1b7 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -61,6 +61,7 @@ describe("useAgentChatSession", () => { jest.mocked(streamAgentChat).mockImplementation(async () => undefined); deleteChatSession.mockImplementation(async () => undefined); saveActiveChatState.mockImplementation(async (state) => state.sessionId); + updateChatSessionTitle.mockImplementation(async () => undefined); }); it("does not add a new empty session to history until there is actual chat content", async () => { @@ -522,6 +523,64 @@ describe("useAgentChatSession", () => { ); }); + it("does not apply a late generated title to a newly created session", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + let resolveStream: (() => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise<void>((resolve) => { + resolveStream = resolve; + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + void result.current.sendPrompt("帮我分析一下"); + await Promise.resolve(); + }); + + act(() => { + emitStreamEvent?.({ + type: "done", + sessionId: "old-session", + }); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + act(() => { + result.current.createSession(); + }); + + expect(result.current.sessionTitle).toBe("新对话"); + + await act(async () => { + emitStreamEvent?.({ + type: "session_title", + sessionId: "old-session", + title: "旧请求标题", + }); + resolveStream?.(); + await Promise.resolve(); + }); + + expect(result.current.sessionTitle).toBe("新对话"); + expect(updateChatSessionTitle).toHaveBeenCalledWith( + "old-session", + "旧请求标题", + { isTitleManuallyEdited: false }, + ); + }); + it("asks the backend to undo the previous user turn before regenerating", async () => { listChatSessions.mockResolvedValue([]); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index f9145d2..f046270 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -234,6 +234,7 @@ export const useAgentChatSession = ({ const abortRef = useRef<AbortController | null>(null); const sessionIdRef = useRef<string | undefined>(undefined); const messagesRef = useRef<Message[]>([]); + const branchGroupsRef = useRef<BranchGroup[]>([]); const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null); const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef<Promise<void> | null>(null); @@ -256,6 +257,10 @@ export const useAgentChatSession = ({ messagesRef.current = messages; }, [messages]); + useEffect(() => { + branchGroupsRef.current = branchGroups; + }, [branchGroups]); + useEffect(() => { isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; }, [isSessionTitleManuallyEdited]); @@ -444,7 +449,12 @@ export const useAgentChatSession = ({ assistantMessageId?: string; }, ) => { - if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) { + if ( + event.type !== "session_title" && + "sessionId" in event && + event.sessionId && + event.sessionId !== sessionIdRef.current + ) { sessionIdRef.current = event.sessionId; setSessionId(event.sessionId); } @@ -457,6 +467,39 @@ export const useAgentChatSession = ({ return; } + if (event.type === "session_title") { + const nextTitle = event.title.trim(); + if (nextTitle && !isSessionTitleManuallyEditedRef.current) { + const currentSessionId = sessionIdRef.current; + const targetSessionId = event.sessionId || currentSessionId; + if (targetSessionId === currentSessionId) { + setSessionTitle(nextTitle); + lastPersistedStateKeyRef.current = createPersistedStateKey({ + sessionId: targetSessionId, + title: nextTitle, + isTitleManuallyEdited: false, + messages: messagesRef.current, + branchGroups: branchGroupsRef.current, + }); + } + if (targetSessionId) { + const currentNonce = ++titleUpdateNonceRef.current; + void updateChatSessionTitle(targetSessionId, nextTitle, { + isTitleManuallyEdited: false, + }) + .then(() => listChatSessions()) + .then((sessions) => { + if (titleUpdateNonceRef.current !== currentNonce) return; + setChatSessions(sessions); + }) + .catch((error) => { + console.error("[GlobalChatbox] Failed to persist session title:", error); + }); + } + } + return; + } + const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId); if (!assistantMessageId) { return; @@ -519,26 +562,6 @@ export const useAgentChatSession = ({ }; }), ); - } else if (event.type === "session_title") { - const nextTitle = event.title.trim(); - if (nextTitle && !isSessionTitleManuallyEditedRef.current) { - setSessionTitle(nextTitle); - const currentSessionId = sessionIdRef.current; - if (currentSessionId) { - const currentNonce = ++titleUpdateNonceRef.current; - void updateChatSessionTitle(currentSessionId, nextTitle, { - isTitleManuallyEdited: false, - }) - .then(() => listChatSessions()) - .then((sessions) => { - if (titleUpdateNonceRef.current !== currentNonce) return; - setChatSessions(sessions); - }) - .catch((error) => { - console.error("[GlobalChatbox] Failed to persist session title:", error); - }); - } - } } else if (event.type === "done") { setMessages((prev) => prev.map((message) => { -- 2.54.0 From 2691f425818431bdc51d65b1a707871fa336afd2 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 16:07:39 +0800 Subject: [PATCH 167/281] refactor: simplify chat fork flow --- src/components/chat/AgentTurn.tsx | 370 +++++------------- src/components/chat/AgentWorkspace.test.tsx | 5 +- src/components/chat/AgentWorkspace.tsx | 124 ++---- src/components/chat/GlobalChatbox.tsx | 10 +- src/components/chat/GlobalChatbox.types.ts | 29 -- src/components/chat/GlobalChatbox.utils.ts | 11 +- src/components/chat/chatStorage.test.ts | 3 - src/components/chat/chatStorage.ts | 12 +- .../chat/hooks/useAgentChatSession.test.tsx | 95 ++++- .../chat/hooks/useAgentChatSession.ts | 271 +++---------- 10 files changed, 274 insertions(+), 656 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 9b53bc1..9043a8d 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -23,17 +23,14 @@ import { import type { Theme } from "@mui/material/styles"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import RefreshRounded from "@mui/icons-material/RefreshRounded"; -import EditRounded from "@mui/icons-material/EditRounded"; -import CloseRounded from "@mui/icons-material/CloseRounded"; -import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded"; -import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded"; +import { TbArrowsSplit2 } from "react-icons/tb"; import { parseAssistantMessageSections, parseContentWithToolCalls, type ContentSegment, } from "./chatMessageSections"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; -import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types"; +import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChatInlineChart } from "./ChatInlineChart"; @@ -45,7 +42,6 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import StopRounded from "@mui/icons-material/StopRounded"; -import SendRounded from "@mui/icons-material/SendRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import TerminalRounded from "@mui/icons-material/TerminalRounded"; import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; @@ -58,24 +54,34 @@ import type { PermissionReply } from "@/lib/chatStream"; type AgentTurnProps = { message: Message; - branchState?: BranchState; messageSpeechState: SpeechState; onSpeak: (messageId: string, text: string) => void; onPause: () => void; onResume: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: () => void; - onEditResubmit: (messageId: string, newContent: string) => void; - onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onRegenerate: (messageId: string) => void; + onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; -const MarkdownBlock = ({ children }: { children: string }) => ( - <div className={markdownStyles.markdown}> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> - </div> -); +const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); + +const MarkdownBlock = ({ children }: { children: string }) => { + const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => { + const selectedText = window.getSelection()?.toString(); + if (!selectedText) return; + + event.preventDefault(); + event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); + }, []); + + return ( + <div className={markdownStyles.markdown} onCopy={handleCopy}> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> + </div> + ); +}; const formatMetadataValue = (value: unknown) => { if (typeof value === "string") { @@ -667,7 +673,6 @@ const PermissionRequestGroup = ({ export const AgentTurn = React.memo( ({ message, - branchState, messageSpeechState, onSpeak, onPause, @@ -675,17 +680,13 @@ export const AgentTurn = React.memo( onStopSpeech, isTtsSupported, onRegenerate, - onEditResubmit, - onCycleBranch, + onCreateBranch, onReplyPermission, }: AgentTurnProps) => { const theme = useTheme(); const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); const [isHovered, setIsHovered] = React.useState(false); - const [isEditing, setIsEditing] = React.useState(false); - const [editDraft, setEditDraft] = React.useState(message.content); - const rootMessageId = message.branchRootId ?? message.id; const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", ) ?? false; @@ -720,185 +721,33 @@ export const AgentTurn = React.memo( onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - {isEditing ? ( - <Paper - elevation={12} - sx={{ - p: 1.5, - borderRadius: 5, - bgcolor: alpha("#ffffff", 0.75), - backdropFilter: "blur(40px)", - border: `1px solid ${alpha("#ffffff", 0.9)}`, - boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`, - minWidth: { xs: 260, sm: 320, md: 400 }, - maxWidth: "100%", - }} - > - <Box component="textarea" - autoFocus - value={editDraft} - onChange={(e) => setEditDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (editDraft.trim() !== message.content) { - onEditResubmit(message.id, editDraft); - } - setIsEditing(false); - } else if (e.key === "Escape") { - setEditDraft(message.content); - setIsEditing(false); - } - }} - sx={{ - width: "100%", - minHeight: 60, - bgcolor: "transparent", - border: "none", - outline: "none", - resize: "none", - fontFamily: "inherit", - fontSize: "1rem", - color: "text.primary", - lineHeight: 1.6, - }} - /> - <Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}> - <IconButton - size="small" - aria-label="取消" - onClick={() => { setEditDraft(message.content); setIsEditing(false); }} - sx={{ - bgcolor: alpha("#000", 0.05), - color: "text.secondary", - width: 34, height: 34, - "&:hover": { bgcolor: alpha("#000", 0.1) } - }} - > - <CloseRounded fontSize="small" /> - </IconButton> - <IconButton - size="small" - aria-label="发送修改" - disabled={editDraft.trim() === "" || editDraft.trim() === message.content} - onClick={() => { - onEditResubmit(message.id, editDraft); - setIsEditing(false); - }} - sx={{ - bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1), - color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled", - width: 34, height: 34, - boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none", - "&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) } - }} - > - <SendRounded fontSize="small" sx={{ ml: 0.2 }} /> - </IconButton> - </Stack> - </Paper> - ) : ( - <> - <Paper - elevation={4} - sx={{ - p: 2, - borderRadius: 5, - borderBottomRightRadius: 2, - color: "#fff", - background: `linear-gradient(135deg, #0288d1, #00acc1)`, - boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`, - backdropFilter: "blur(10px)", - "--chat-md-text": alpha("#fff", 0.96), - "--chat-md-heading": "#fff", - "--chat-md-link": "#e0f7fa", - "--chat-md-link-hover": "#fff", - "--chat-md-inline-code-bg": "rgba(255,255,255,0.15)", - "--chat-md-inline-code-border": alpha("#fff", 0.1), - "--chat-md-inline-code-text": "#fff", - "--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)", - "--chat-md-pre-border": alpha("#fff", 0.1), - "--chat-md-pre-text": "#F8FAFC", - "--chat-md-quote-border": alpha("#fff", 0.4), - "--chat-md-quote-bg": alpha("#fff", 0.05), - "--chat-md-quote-text": alpha("#fff", 0.8), - }} - > - <MarkdownBlock>{message.content}</MarkdownBlock> - - <AnimatePresence> - {isHovered && !isEditing && ( - <motion.div - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.9 }} - transition={{ duration: 0.15 }} - style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }} - > - <IconButton - size="small" - onClick={() => { setIsEditing(true); setEditDraft(message.content); }} - aria-label="编辑提问" - sx={{ - width: 26, - height: 26, - bgcolor: alpha("#fff", 0.9), - color: "#00acc1", - boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`, - "&:hover": { bgcolor: "#fff", color: "#00838f" } - }} - > - <EditRounded sx={{ fontSize: 14 }} /> - </IconButton> - </motion.div> - )} - </AnimatePresence> - </Paper> - - {branchState && branchState.total > 1 ? ( - <Stack - direction="row" - justifyContent="flex-end" - sx={{ mt: 0.5, mr: 0.5 }} - > - <Paper - elevation={0} - sx={{ - display: "flex", - alignItems: "center", - gap: 0.5, - px: 0.5, - py: 0.25, - borderRadius: 4, - bgcolor: alpha("#000", 0.04), - backdropFilter: "blur(4px)", - border: `1px solid ${alpha("#000", 0.08)}`, - }} - > - <IconButton - size="small" - aria-label="上一分支" - onClick={() => onCycleBranch(rootMessageId, -1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - <ChevronLeftRounded sx={{ fontSize: 16 }} /> - </IconButton> - <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}> - {branchState.activeIndex + 1} / {branchState.total} - </Typography> - <IconButton - size="small" - aria-label="下一分支" - onClick={() => onCycleBranch(rootMessageId, 1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - <ChevronRightRounded sx={{ fontSize: 16 }} /> - </IconButton> - </Paper> - </Stack> - ) : null} - </> - )} + <Paper + elevation={4} + sx={{ + p: 2, + borderRadius: 5, + borderBottomRightRadius: 2, + color: "#fff", + background: `linear-gradient(135deg, #0288d1, #00acc1)`, + boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`, + backdropFilter: "blur(10px)", + "--chat-md-text": alpha("#fff", 0.96), + "--chat-md-heading": "#fff", + "--chat-md-link": "#e0f7fa", + "--chat-md-link-hover": "#fff", + "--chat-md-inline-code-bg": "rgba(255,255,255,0.15)", + "--chat-md-inline-code-border": alpha("#fff", 0.1), + "--chat-md-inline-code-text": "#fff", + "--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)", + "--chat-md-pre-border": alpha("#fff", 0.1), + "--chat-md-pre-text": "#F8FAFC", + "--chat-md-quote-border": alpha("#fff", 0.4), + "--chat-md-quote-bg": alpha("#fff", 0.05), + "--chat-md-quote-text": alpha("#fff", 0.8), + }} + > + <MarkdownBlock>{message.content}</MarkdownBlock> + </Paper> </motion.div> ); } @@ -1060,7 +909,9 @@ export const AgentTurn = React.memo( size="small" aria-label="复制" onClick={() => { - navigator.clipboard.writeText(message.content); + navigator.clipboard.writeText( + normalizeClipboardText(message.content), + ); // Could add a toast here }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} @@ -1073,13 +924,25 @@ export const AgentTurn = React.memo( size="small" aria-label="重新生成" onClick={() => { - onRegenerate(); + onRegenerate(message.id); }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} > <RefreshRounded sx={{ fontSize: 16 }} /> </IconButton> </Tooltip> + <Tooltip title="拆分为新会话"> + <IconButton + size="small" + aria-label="拆分为新会话" + onClick={() => { + onCreateBranch(message.id); + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + <TbArrowsSplit2 size={16} /> + </IconButton> + </Tooltip> </Paper> </motion.div> )} @@ -1088,87 +951,40 @@ export const AgentTurn = React.memo( </Paper> </Stack> - {(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? ( + {!isErrorMessage && isTtsSupported ? ( <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}> <Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}> - {!isErrorMessage && isTtsSupported ? ( + {messageSpeechState === "idle" ? ( + <IconButton + size="small" + onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} + aria-label="朗读消息" + sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} + > + <VolumeUpRounded sx={{ fontSize: 16 }} /> + </IconButton> + ) : null} + {messageSpeechState === "playing" ? ( <> - {messageSpeechState === "idle" ? ( - <IconButton - size="small" - onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} - aria-label="朗读消息" - sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} - > - <VolumeUpRounded sx={{ fontSize: 16 }} /> - </IconButton> - ) : null} - {messageSpeechState === "playing" ? ( - <> - <IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> - <PauseRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> - ) : null} - {messageSpeechState === "paused" ? ( - <> - <IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}> - <PlayArrowRounded sx={{ fontSize: 16 }} /> - </IconButton> - <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> - <StopRounded sx={{ fontSize: 16 }} /> - </IconButton> - </> - ) : null} + <IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> + <PauseRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + ) : null} + {messageSpeechState === "paused" ? ( + <> + <IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}> + <PlayArrowRounded sx={{ fontSize: 16 }} /> + </IconButton> + <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> </> ) : null} </Stack> - - {branchState && branchState.total > 1 ? ( - <Stack - direction="row" - justifyContent="flex-start" - sx={{ mr: 0.5 }} - > - <Paper - elevation={0} - sx={{ - display: "flex", - alignItems: "center", - gap: 0.5, - px: 0.5, - py: 0.25, - borderRadius: 4, - bgcolor: alpha("#000", 0.04), - backdropFilter: "blur(4px)", - border: `1px solid ${alpha("#000", 0.08)}`, - }} - > - <IconButton - size="small" - aria-label="上一分支" - onClick={() => onCycleBranch(rootMessageId, -1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - <ChevronLeftRounded sx={{ fontSize: 16 }} /> - </IconButton> - <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}> - {branchState.activeIndex + 1} / {branchState.total} - </Typography> - <IconButton - size="small" - aria-label="下一分支" - onClick={() => onCycleBranch(rootMessageId, 1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - <ChevronRightRounded sx={{ fontSize: 16 }} /> - </IconButton> - </Paper> - </Stack> - ) : null} </Stack> ) : null} </motion.div> diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index 9efa895..20bc225 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -33,8 +33,6 @@ jest.mock("./AgentTurn", () => ({ describe("AgentWorkspace", () => { const defaultProps = { - branchGroups: [], - branchTransition: null, bottomRef: { current: null }, speakingMessageId: null, speechState: "idle" as const, @@ -44,8 +42,7 @@ describe("AgentWorkspace", () => { onStopSpeech: jest.fn(), isTtsSupported: false, onRegenerate: jest.fn(), - onEditResubmit: jest.fn(), - onCycleBranch: jest.fn(), + onCreateBranch: jest.fn(), onReplyPermission: jest.fn(), }; diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index ba15fa8..37e415f 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -13,17 +13,12 @@ import { AgentTurn } from "./AgentTurn"; import { TypingIndicator } from "./GlobalChatbox.parts"; import type { PermissionReply } from "@/lib/chatStream"; import type { - BranchGroup, - BranchState, - BranchTransition, Message, SpeechState, } from "./GlobalChatbox.types"; type AgentWorkspaceProps = { messages: Message[]; - branchGroups: BranchGroup[]; - branchTransition: BranchTransition | null; isStreaming: boolean; bottomRef: React.RefObject<HTMLDivElement | null>; speakingMessageId: string | null; @@ -33,15 +28,13 @@ type AgentWorkspaceProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: () => void; - onEditResubmit: (messageId: string, newContent: string) => void; - onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onRegenerate: (messageId: string) => void; + onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; type TurnListProps = { messages: Message[]; - branchGroups: BranchGroup[]; speakingMessageId: string | null; speechState: SpeechState; onSpeak: (messageId: string, text: string) => void; @@ -49,9 +42,8 @@ type TurnListProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: () => void; - onEditResubmit: (messageId: string, newContent: string) => void; - onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onRegenerate: (messageId: string) => void; + onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; @@ -61,7 +53,6 @@ const sameMessages = (left: Message[], right: Message[]) => const TurnListInner = ({ messages, - branchGroups, speakingMessageId, speechState, onSpeak, @@ -70,45 +61,26 @@ const TurnListInner = ({ onStopSpeech, isTtsSupported, onRegenerate, - onEditResubmit, - onCycleBranch, + onCreateBranch, onReplyPermission, }: TurnListProps) => { - const branchStateByRootId = React.useMemo(() => { - const next = new Map<string, BranchState>(); - branchGroups.forEach((group) => { - if (group.branches.length > 1) { - next.set(group.rootMessageId, { - activeIndex: group.activeIndex, - total: group.branches.length, - }); - } - }); - return next; - }, [branchGroups]); - return ( <> - {messages.map((message) => { - const rootMessageId = message.branchRootId ?? message.id; - return ( - <AgentTurn - key={rootMessageId} - message={message} - branchState={branchStateByRootId.get(rootMessageId)} - messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} - onSpeak={onSpeak} - onPause={onPauseSpeech} - onResume={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - onRegenerate={onRegenerate} - onEditResubmit={onEditResubmit} - onCycleBranch={onCycleBranch} - onReplyPermission={onReplyPermission} - /> - ); - })} + {messages.map((message) => ( + <AgentTurn + key={message.id} + message={message} + messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} + onSpeak={onSpeak} + onPause={onPauseSpeech} + onResume={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={onRegenerate} + onCreateBranch={onCreateBranch} + onReplyPermission={onReplyPermission} + /> + ))} </> ); }; @@ -117,7 +89,6 @@ const TurnList = React.memo( TurnListInner, (prevProps, nextProps) => sameMessages(prevProps.messages, nextProps.messages) && - prevProps.branchGroups === nextProps.branchGroups && prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speechState === nextProps.speechState && prevProps.onSpeak === nextProps.onSpeak && @@ -126,8 +97,7 @@ const TurnList = React.memo( prevProps.onStopSpeech === nextProps.onStopSpeech && prevProps.isTtsSupported === nextProps.isTtsSupported && prevProps.onRegenerate === nextProps.onRegenerate && - prevProps.onEditResubmit === nextProps.onEditResubmit && - prevProps.onCycleBranch === nextProps.onCycleBranch && + prevProps.onCreateBranch === nextProps.onCreateBranch && prevProps.onReplyPermission === nextProps.onReplyPermission, ); @@ -249,8 +219,6 @@ const EmptyState = () => { export const AgentWorkspace = ({ messages, - branchGroups, - branchTransition, isStreaming, bottomRef, speakingMessageId, @@ -261,8 +229,7 @@ export const AgentWorkspace = ({ onStopSpeech, isTtsSupported, onRegenerate, - onEditResubmit, - onCycleBranch, + onCreateBranch, onReplyPermission, }: AgentWorkspaceProps) => { const theme = useTheme(); @@ -274,18 +241,12 @@ export const AgentWorkspace = ({ (!latestAssistant || (latestAssistant.content.trim().length === 0 && !(latestAssistant.artifacts?.length))); - const stableMessages = branchTransition - ? messages.slice(0, branchTransition.parentCount) - : messages; - const transitionMessages = branchTransition - ? messages.slice(branchTransition.parentCount) - : []; const streamingMessage = - !branchTransition && isStreaming && messages.at(-1)?.role === "assistant" + isStreaming && messages.at(-1)?.role === "assistant" ? messages.at(-1) : undefined; const historyMessages = - streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages; + streamingMessage !== undefined ? messages.slice(0, -1) : messages; return ( <Box @@ -307,7 +268,6 @@ export const AgentWorkspace = ({ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <TurnList messages={historyMessages} - branchGroups={branchGroups} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={onSpeak} @@ -316,15 +276,13 @@ export const AgentWorkspace = ({ onStopSpeech={onStopSpeech} isTtsSupported={isTtsSupported} onRegenerate={onRegenerate} - onEditResubmit={onEditResubmit} - onCycleBranch={onCycleBranch} + onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} /> {streamingMessage ? ( <TurnList messages={[streamingMessage]} - branchGroups={branchGroups} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={onSpeak} @@ -333,40 +291,10 @@ export const AgentWorkspace = ({ onStopSpeech={onStopSpeech} isTtsSupported={isTtsSupported} onRegenerate={onRegenerate} - onEditResubmit={onEditResubmit} - onCycleBranch={onCycleBranch} + onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} /> ) : null} - - {branchTransition ? ( - <AnimatePresence initial={false} mode="wait"> - <motion.div - key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`} - initial={{ opacity: 0, y: 8 }} - animate={{ opacity: 1, y: 0 }} - exit={{ opacity: 0, y: -8 }} - transition={{ duration: 0.18, ease: "easeOut" }} - style={{ display: "flex", flexDirection: "column", gap: 16 }} - > - <TurnList - messages={transitionMessages} - branchGroups={branchGroups} - speakingMessageId={speakingMessageId} - speechState={speechState} - onSpeak={onSpeak} - onPauseSpeech={onPauseSpeech} - onResumeSpeech={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - onRegenerate={onRegenerate} - onEditResubmit={onEditResubmit} - onCycleBranch={onCycleBranch} - onReplyPermission={onReplyPermission} - /> - </motion.div> - </AnimatePresence> - ) : null} </Box> ) : null} diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index a9d674c..d1d1192 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -67,15 +67,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { messages, chatSessions, activeSessionId, - branchGroups, - branchTransition, isHydrating, isStreaming, sessionTitle, sendPrompt, regenerate, - editAndResubmit, - cycleBranch, + createBranch, abort, replyPermission, createSession, @@ -344,8 +341,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}> <AgentWorkspace messages={messages} - branchGroups={branchGroups} - branchTransition={branchTransition} isStreaming={isStreaming} bottomRef={bottomRef} speakingMessageId={speakingMessageId} @@ -356,8 +351,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onStopSpeech={handleStopSpeech} isTtsSupported={isTtsSupported} onRegenerate={regenerate} - onEditResubmit={editAndResubmit} - onCycleBranch={cycleBranch} + onCreateBranch={createBranch} onReplyPermission={replyPermission} /> diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 8e29200..2ff19ff 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -55,34 +55,6 @@ export type Message = { progress?: ChatProgress[]; artifacts?: AgentArtifact[]; permissions?: AgentPermissionRequest[]; - branchRootId?: string; -}; - -export type BranchState = { - activeIndex: number; - total: number; -}; - -export type MessageBranch = { - id: string; - label: string; - sessionId?: string; - messages: Message[]; -}; - -export type BranchGroup = { - id: string; - rootMessageId: string; - parentCount: number; - activeIndex: number; - branches: MessageBranch[]; -}; - -export type BranchTransition = { - rootMessageId: string; - parentCount: number; - activeBranchId: string; - nonce: number; }; export type Props = { @@ -106,7 +78,6 @@ export type LoadedChatState = { title?: string; isTitleManuallyEdited?: boolean; messages: Message[]; - branchGroups: BranchGroup[]; isStreaming?: boolean; runStatus?: string; }; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index eb6ccf2..4564a52 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -1,4 +1,4 @@ -import type { BranchGroup, Message } from "./GlobalChatbox.types"; +import type { Message } from "./GlobalChatbox.types"; export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -36,12 +36,3 @@ export const cloneMessage = (message: Message): Message => ({ }); export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage); - -export const cloneBranchGroups = (branchGroups: BranchGroup[]) => - branchGroups.map((group) => ({ - ...group, - branches: group.branches.map((branch) => ({ - ...branch, - messages: cloneMessages(branch.messages), - })), - })); diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index f1117c9..1575f86 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -21,7 +21,6 @@ describe("chatStorage backend-only persistence", () => { title: undefined, messages: [], sessionId: undefined, - branchGroups: [], }); expect(apiFetch).not.toHaveBeenCalled(); }); @@ -60,11 +59,9 @@ describe("chatStorage backend-only persistence", () => { id: "message-2", role: "user", content: "第一条消息", - branchRootId: "message-2", }, ], sessionId: undefined, - branchGroups: [], }, ); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 81d5506..30ee6ff 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -2,12 +2,11 @@ import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; import type { - BranchGroup, ChatSessionSummary, LoadedChatState, Message, } from "./GlobalChatbox.types"; -import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; +import { cloneMessages } from "./GlobalChatbox.utils"; type BackendSessionPayload = { id?: string; @@ -23,22 +22,16 @@ export const createEmptyChatState = (): LoadedChatState => ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); const sanitizeMessages = (messages: Message[] | undefined) => Array.isArray(messages) ? cloneMessages(messages) : []; -const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => - Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; - const hasChatContent = (state: { messages: Message[]; - branchGroups: BranchGroup[]; sessionId?: string; }) => state.messages.length > 0 || - state.branchGroups.length > 0 || Boolean(state.sessionId); const compareSessionsByAnchorTime = ( @@ -107,7 +100,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta is_title_manually_edited?: boolean; session_id?: string; messages?: Message[]; - branch_groups?: BranchGroup[]; is_streaming?: boolean; run_status?: string; }; @@ -116,7 +108,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta isTitleManuallyEdited: payload.is_title_manually_edited ?? false, messages: sanitizeMessages(payload.messages), sessionId: payload.session_id ?? payload.id, - branchGroups: sanitizeBranchGroups(payload.branch_groups), isStreaming: payload.is_streaming ?? false, runStatus: payload.run_status, }; @@ -167,7 +158,6 @@ const saveBackendChatState = async ( title: normalizeTitle(state.title), is_title_manually_edited: state.isTitleManuallyEdited ?? false, messages: sanitizeMessages(state.messages), - branch_groups: sanitizeBranchGroups(state.branchGroups), }), projectHeaderMode: "include", userHeaderMode: "include", diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index f7eb1b7..060dd1e 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -5,6 +5,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; import { abortAgentChat, + forkAgentChat, replyAgentPermission, resumeAgentChatStream, streamAgentChat, @@ -30,7 +31,6 @@ jest.mock("../chatStorage", () => ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], })), deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), listChatSessions: (...args: unknown[]) => listChatSessions(...args), @@ -39,7 +39,6 @@ jest.mock("../chatStorage", () => ({ isTitleManuallyEdited: false, messages: [], sessionId: "session-loaded", - branchGroups: [], })), saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), @@ -52,10 +51,12 @@ describe("useAgentChatSession", () => { saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); + jest.mocked(forkAgentChat).mockReset(); jest.mocked(replyAgentPermission).mockReset(); jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); @@ -596,9 +597,10 @@ describe("useAgentChatSession", () => { await act(async () => { await result.current.sendPrompt("重新分析压力异常"); }); + const assistantMessageId = result.current.messages[1]?.id ?? ""; await act(async () => { - await result.current.regenerate(); + await result.current.regenerate(assistantMessageId); }); expect(streamAgentChat).toHaveBeenNthCalledWith( @@ -609,4 +611,91 @@ describe("useAgentChatSession", () => { }), ); }); + + it("replaces the current chain when regenerating a middle assistant message", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("第一轮"); + }); + + await act(async () => { + await result.current.sendPrompt("第二轮"); + }); + + const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.regenerate(firstAssistantMessageId); + }); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: "user", + content: "第一轮", + }), + ); + expect(result.current.messages[1]).toEqual( + expect.objectContaining({ + role: "assistant", + content: "", + }), + ); + expect(streamAgentChat).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + message: "第一轮", + regenerateFromMessageIndex: 0, + }), + ); + }); + + it("forks a copied conversation from an assistant message", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("第一轮"); + }); + + const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.createBranch(firstAssistantMessageId); + }); + + expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2); + expect(result.current.activeSessionId).toBe("forked-session"); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: "user", + content: "第一轮", + }), + ); + expect(result.current.messages[1]).toEqual( + expect.objectContaining({ + role: "assistant", + }), + ); + expect(streamAgentChat).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index f046270..99dab87 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -18,15 +18,12 @@ import type { import type { AgentArtifact, AgentPermissionRequest, - BranchGroup, - BranchTransition, ChatProgress, ChatSessionSummary, LoadedChatState, Message, } from "../GlobalChatbox.types"; import { - cloneBranchGroups, cloneMessages, createId, } from "../GlobalChatbox.utils"; @@ -68,7 +65,6 @@ const createPersistedStateKey = (state: LoadedChatState) => isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, sessionId: state.sessionId ?? null, messages: state.messages, - branchGroups: state.branchGroups, }); const upsertProgress = ( @@ -193,13 +189,12 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => { }; }; -const createUserMessage = (content: string, branchRootId?: string): Message => { +const createUserMessage = (content: string): Message => { const id = createId(); return { id, role: "user", content, - branchRootId: branchRootId ?? id, }; }; @@ -209,9 +204,6 @@ const createAssistantMessage = (): Message => ({ content: "", }); -const messagesEqual = (left: Message[], right: Message[]) => - JSON.stringify(left) === JSON.stringify(right); - export const useAgentChatSession = ({ projectId, onToolCall, @@ -226,15 +218,12 @@ export const useAgentChatSession = ({ const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined); const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false); const [sessionId, setSessionId] = useState<string | undefined>(undefined); - const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]); const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); - const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null); const [isStreaming, setIsStreaming] = useState(false); const [isHydrating, setIsHydrating] = useState(true); const abortRef = useRef<AbortController | null>(null); const sessionIdRef = useRef<string | undefined>(undefined); const messagesRef = useRef<Message[]>([]); - const branchGroupsRef = useRef<BranchGroup[]>([]); const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null); const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef<Promise<void> | null>(null); @@ -245,7 +234,6 @@ export const useAgentChatSession = ({ title: undefined, isTitleManuallyEdited: false, messages: [], - branchGroups: [], }), ); @@ -257,9 +245,6 @@ export const useAgentChatSession = ({ messagesRef.current = messages; }, [messages]); - useEffect(() => { - branchGroupsRef.current = branchGroups; - }, [branchGroups]); useEffect(() => { isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; @@ -279,17 +264,14 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - setBranchTransition(null); setMessages([]); setSessionTitle(undefined); setIsSessionTitleManuallyEdited(false); setSessionId(undefined); - setBranchGroups([]); setChatSessions([]); setIsHydrating(false); return; @@ -313,7 +295,6 @@ export const useAgentChatSession = ({ setSessionTitle(loadedState.title); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setSessionId(loadedState.sessionId); - setBranchGroups(loadedState.branchGroups); setChatSessions(sessions); if ( loadedState.sessionId && @@ -351,7 +332,6 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: isSessionTitleManuallyEdited, messages, sessionId, - branchGroups, }; const currentStateKey = createPersistedStateKey(state); @@ -381,46 +361,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); - - useEffect(() => { - setBranchGroups((prev) => { - let changed = false; - const next = prev.map((group) => { - const rootMessage = messages[group.parentCount]; - if ( - !rootMessage || - rootMessage.role !== "user" || - (rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId - ) { - return group; - } - - const activeBranch = group.branches[group.activeIndex]; - if (!activeBranch) { - return group; - } - - const nextSuffix = cloneMessages(messages.slice(group.parentCount)); - if ( - activeBranch.sessionId === sessionId && - messagesEqual(activeBranch.messages, nextSuffix) - ) { - return group; - } - - changed = true; - const branches = group.branches.map((branch, index) => - index === group.activeIndex - ? { ...branch, sessionId, messages: nextSuffix } - : branch, - ); - return { ...group, branches }; - }); - - return changed ? next : prev; - }); - }, [messages, sessionId]); + }, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { setMessages((prev) => @@ -479,7 +420,6 @@ export const useAgentChatSession = ({ title: nextTitle, isTitleManuallyEdited: false, messages: messagesRef.current, - branchGroups: branchGroupsRef.current, }); } if (targetSessionId) { @@ -643,7 +583,6 @@ export const useAgentChatSession = ({ await cancelPromiseRef.current?.catch(() => undefined); onBeforeSend?.(); - setBranchTransition(null); const nextUserMessage = userMessage ?? createUserMessage(prompt); const nextAssistantMessage = assistantMessage ?? createAssistantMessage(); @@ -832,7 +771,6 @@ export const useAgentChatSession = ({ const controller = abortRef.current; controller?.abort(); - setBranchTransition(null); hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = undefined; @@ -841,13 +779,11 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); setMessages([]); setSessionTitle("新对话"); setIsSessionTitleManuallyEdited(false); setSessionId(undefined); - setBranchGroups([]); setIsStreaming(false); }, [isHydrating, isStreaming]); @@ -868,12 +804,10 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); - setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); - setBranchGroups(nextState.branchGroups); setChatSessions(sessions); if (nextState.sessionId && nextState.isStreaming) { resumeStreamingSession(nextState.sessionId); @@ -917,14 +851,11 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); - setBranchTransition(null); setMessages([]); setSessionTitle(undefined); setIsSessionTitleManuallyEdited(false); setSessionId(undefined); - setBranchGroups([]); return; } @@ -937,12 +868,10 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); - setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); - setBranchGroups(nextState.branchGroups); setChatSessions(sessionsAfterDelete); } catch (error) { console.error("[GlobalChatbox] Failed to delete chat session:", error); @@ -985,183 +914,99 @@ export const useAgentChatSession = ({ title: normalizedTitle, isTitleManuallyEdited: true, messages, - branchGroups, }); } } catch (error) { console.error("[GlobalChatbox] Failed to rename chat session:", error); } }, - [branchGroups, isHydrating, messages], + [isHydrating, messages], ); - const regenerate = useCallback(async () => { + const regenerate = useCallback(async (messageId: string) => { if (isHydrating || isStreaming || messages.length === 0) return; - let lastUserIndex = messages.length - 1; - while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") { - lastUserIndex--; + const targetAssistantIndex = messages.findIndex( + (message) => message.id === messageId && message.role === "assistant", + ); + if (targetAssistantIndex < 0) { + return; } - if (lastUserIndex < 0) return; + let targetUserIndex = targetAssistantIndex - 1; + while (targetUserIndex >= 0 && messages[targetUserIndex].role !== "user") { + targetUserIndex--; + } - const lastUser = messages[lastUserIndex]; - const lastUserContent = lastUser.content; - const nextMessages = cloneMessages(messages.slice(0, lastUserIndex)); - const nextUserMessage = createUserMessage( - lastUserContent, - lastUser.branchRootId ?? lastUser.id, - ); - const nextAssistantMessage = createAssistantMessage(); + if (targetUserIndex < 0) return; - setMessages(nextMessages); - await runPrompt({ - prompt: lastUserContent, - regenerateFromMessageIndex: lastUserIndex, - preparedMessages: [ - ...nextMessages, - nextUserMessage, - nextAssistantMessage, - ], - userMessage: nextUserMessage, - assistantMessage: nextAssistantMessage, - }); - }, [isHydrating, isStreaming, messages, runPrompt]); + const targetUser = messages[targetUserIndex]; + const targetUserContent = targetUser.content; + const nextMessages = cloneMessages(messages.slice(0, targetUserIndex)); + const nextUserMessage = createUserMessage(targetUserContent); + const nextAssistantMessage = createAssistantMessage(); - const editAndResubmit = useCallback( - async (messageId: string, newContent: string) => { + setMessages(nextMessages); + await runPrompt({ + prompt: targetUserContent, + regenerateFromMessageIndex: targetUserIndex, + preparedMessages: [ + ...nextMessages, + nextUserMessage, + nextAssistantMessage, + ], + userMessage: nextUserMessage, + assistantMessage: nextAssistantMessage, + }); + }, [isHydrating, isStreaming, messages, runPrompt]); + + const createBranch = useCallback( + async (messageId: string) => { if (isHydrating || isStreaming) return; - const trimmedContent = newContent.trim(); - if (!trimmedContent) return; + const assistantIndex = messages.findIndex( + (message) => message.id === messageId && message.role === "assistant", + ); + if (assistantIndex < 0) return; - const messageIndex = messages.findIndex((m) => m.id === messageId); - if (messageIndex < 0 || messages[messageIndex].role !== "user") return; - - const originalMessage = messages[messageIndex]; - if (trimmedContent === originalMessage.content.trim()) return; - - const rootMessageId = originalMessage.branchRootId ?? originalMessage.id; const currentSessionId = sessionIdRef.current; - const keepMessageCount = messageIndex; - const prefix = cloneMessages(messages.slice(0, messageIndex)); - const originalSuffix = cloneMessages(messages.slice(messageIndex)); + const keepMessageCount = assistantIndex + 1; + const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount)); const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount); - const nextUserMessage = createUserMessage(trimmedContent, rootMessageId); - const nextAssistantMessage = createAssistantMessage(); - const nextSuffix = [nextUserMessage, nextAssistantMessage]; - - setBranchGroups((prev) => { - const next = cloneBranchGroups(prev); - const groupIndex = next.findIndex( - (group) => - group.rootMessageId === rootMessageId && group.parentCount === messageIndex, - ); - - if (groupIndex >= 0) { - const group = next[groupIndex]; - group.branches[group.activeIndex] = { - ...group.branches[group.activeIndex], - sessionId: currentSessionId, - messages: originalSuffix, - }; - group.branches.push({ - id: createId(), - label: `分支 ${group.branches.length + 1}`, - sessionId: forkedSessionId, - messages: cloneMessages(nextSuffix), - }); - group.activeIndex = group.branches.length - 1; - } else { - next.push({ - id: rootMessageId, - rootMessageId, - parentCount: messageIndex, - activeIndex: 1, - branches: [ - { - id: createId(), - label: "分支 1", - sessionId: currentSessionId, - messages: originalSuffix, - }, - { - id: createId(), - label: "分支 2", - sessionId: forkedSessionId, - messages: cloneMessages(nextSuffix), - }, - ], - }); - } - - return next; - }); - sessionIdRef.current = forkedSessionId; setSessionId(forkedSessionId); - await runPrompt({ - prompt: trimmedContent, - sessionIdOverride: forkedSessionId, - preparedMessages: [...prefix, ...nextSuffix], - userMessage: nextUserMessage, - assistantMessage: nextAssistantMessage, - }); - }, - [isHydrating, isStreaming, messages, runPrompt], - ); - - const cycleBranch = useCallback( - (rootMessageId: string, direction: -1 | 1) => { - if (isHydrating || isStreaming) return; - - setBranchGroups((prev) => { - const next = cloneBranchGroups(prev); - const group = next.find((item) => item.rootMessageId === rootMessageId); - if (!group || group.branches.length < 2) { - return prev; - } - - const nextIndex = - (group.activeIndex + direction + group.branches.length) % group.branches.length; - const selectedBranch = group.branches[nextIndex]; - group.activeIndex = nextIndex; - - const nextMessages = [ - ...cloneMessages(messages.slice(0, group.parentCount)), - ...cloneMessages(selectedBranch.messages), - ]; - setBranchTransition({ - rootMessageId, - parentCount: group.parentCount, - activeBranchId: selectedBranch.id, - nonce: Date.now(), + messagesRef.current = copiedMessages; + setMessages(copiedMessages); + setIsSessionTitleManuallyEdited(false); + const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本"; + setSessionTitle(forkTitle); + try { + await saveActiveChatState({ + title: forkTitle, + isTitleManuallyEdited: false, + messages: copiedMessages, + sessionId: forkedSessionId, }); - sessionIdRef.current = selectedBranch.sessionId; - setSessionId(selectedBranch.sessionId); - setMessages(nextMessages); - - return next; - }); + setChatSessions(await listChatSessions()); + } catch (error) { + console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error); + } }, - [isHydrating, isStreaming, messages], + [isHydrating, isStreaming, messages, sessionTitle], ); return { messages, chatSessions, activeSessionId: sessionIdRef.current, - branchGroups, - branchTransition, isHydrating, isStreaming, sessionTitle, sessionId, sendPrompt, regenerate, - editAndResubmit, - cycleBranch, + createBranch, abort, replyPermission, createSession, -- 2.54.0 From b23cb6acddf96d02f3946cb686921199e571267f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 18:10:28 +0800 Subject: [PATCH 168/281] fix(chat): wire question and todo cards --- src/components/chat/AgentTurn.tsx | 613 ++++++++++++++++++ src/components/chat/AgentWorkspace.test.tsx | 2 + src/components/chat/AgentWorkspace.tsx | 18 +- src/components/chat/GlobalChatbox.tsx | 4 + src/components/chat/GlobalChatbox.types.ts | 7 + .../chat/hooks/useAgentChatSession.test.tsx | 368 +++++++++++ .../chat/hooks/useAgentChatSession.ts | 384 ++++++++++- src/lib/chatStream.test.ts | 100 +++ src/lib/chatStream.ts | 227 +++++++ 9 files changed, 1713 insertions(+), 10 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 9043a8d..7990d0f 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -9,12 +9,15 @@ import { Avatar, Box, Button, + Checkbox, Chip, CircularProgress, Collapse, + FormControlLabel, IconButton, Paper, Stack, + TextField, Tooltip, Typography, alpha, @@ -50,6 +53,9 @@ import BlockRounded from "@mui/icons-material/BlockRounded"; import PushPinRounded from "@mui/icons-material/PushPinRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; +import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; +import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded"; +import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded"; import type { PermissionReply } from "@/lib/chatStream"; type AgentTurnProps = { @@ -63,6 +69,8 @@ type AgentTurnProps = { onRegenerate: (messageId: string) => void; onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; + onReplyQuestion: (requestId: string, answers: string[][]) => void; + onRejectQuestion: (requestId: string) => void; }; const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); @@ -670,6 +678,597 @@ const PermissionRequestGroup = ({ ); }; +const getQuestionStatusLabel = ( + status: NonNullable<Message["questions"]>[number]["status"], +) => { + if (status === "answered") return "已回答"; + if (status === "rejected") return "已跳过"; + if (status === "error") return "提交失败"; + if (status === "submitting") return "提交中"; + return "等待回答"; +}; + +const getQuestionStatusColor = ( + status: NonNullable<Message["questions"]>[number]["status"], + theme: Theme, +) => { + if (status === "answered") return theme.palette.success.main; + if (status === "rejected") return theme.palette.text.secondary; + if (status === "error") return theme.palette.error.main; + return "#0288d1"; +}; + +const QuestionRequestCard = ({ + questionRequest, + onReply, + onReject, +}: { + questionRequest: NonNullable<Message["questions"]>[number]; + onReply: (requestId: string, answers: string[][]) => void; + onReject: (requestId: string) => void; +}) => { + const theme = useTheme(); + const isEditable = + questionRequest.status === "pending" || questionRequest.status === "error"; + const isSubmitting = questionRequest.status === "submitting"; + const statusColor = getQuestionStatusColor(questionRequest.status, theme); + const [selected, setSelected] = React.useState<Record<number, string[]>>({}); + const [custom, setCustom] = React.useState<Record<number, string>>({}); + + const answers = React.useMemo( + () => + questionRequest.questions.map((question, index) => { + const selectedAnswers = selected[index] ?? []; + const customAnswer = custom[index]?.trim(); + return customAnswer ? [...selectedAnswers, customAnswer] : selectedAnswers; + }), + [custom, questionRequest.questions, selected], + ); + + const canSubmit = + isEditable && + questionRequest.questions.length > 0 && + questionRequest.questions.every((question, index) => { + const answer = answers[index] ?? []; + const hasInput = answer.some((item) => item.trim().length > 0); + const canAnswer = question.options.length > 0 || question.custom === true; + return canAnswer && hasInput; + }); + + const answerSummary = (questionRequest.answers ?? []) + .map((answer) => answer.join("、")) + .filter(Boolean) + .join(";"); + + return ( + <Box + sx={{ + borderRadius: 3, + overflow: "hidden", + border: `1px solid ${alpha("#fff", 0.72)}`, + bgcolor: alpha("#fff", 0.52), + boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, + backdropFilter: "blur(20px)", + position: "relative", + "&::before": { + content: '""', + position: "absolute", + inset: "10px auto 10px 0", + width: 3, + borderRadius: "0 999px 999px 0", + bgcolor: statusColor, + }, + }} + > + <Stack + direction="row" + spacing={1} + alignItems="center" + sx={{ + px: 1.5, + py: 1.25, + pl: 1.75, + borderBottom: `1px solid ${alpha("#000", 0.05)}`, + }} + > + <Box + sx={{ + width: 32, + height: 32, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: statusColor, + bgcolor: alpha(statusColor, 0.1), + border: `1px solid ${alpha(statusColor, 0.16)}`, + }} + > + <HelpOutlineRounded sx={{ fontSize: 21 }} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + 需要补充信息 + </Typography> + </Box> + <Chip + size="small" + label={getQuestionStatusLabel(questionRequest.status)} + sx={{ + height: 24, + fontSize: "0.7rem", + fontWeight: 800, + borderRadius: "12px", + bgcolor: alpha(statusColor, 0.12), + color: statusColor, + "& .MuiChip-label": { px: 1 }, + }} + /> + </Stack> + + <Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}> + {questionRequest.questions.map((question, index) => { + const selectedAnswers = selected[index] ?? []; + const setQuestionAnswers = (nextAnswers: string[]) => { + setSelected((current) => ({ + ...current, + [index]: nextAnswers, + })); + }; + + return ( + <Box + key={`${question.header}-${index}`} + sx={{ + px: 1.25, + py: 1, + borderRadius: 2.5, + bgcolor: alpha("#000", 0.025), + border: `1px solid ${alpha("#000", 0.045)}`, + }} + > + <Typography variant="caption" color="text.secondary" fontWeight={800}> + {question.header || `问题 ${index + 1}`} + </Typography> + <Typography + variant="body2" + color="text.primary" + sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }} + > + {question.question} + </Typography> + + {question.options.length ? ( + <Stack spacing={0.75} sx={{ mt: 1 }}> + {question.options.map((option) => { + const checked = selectedAnswers.includes(option.label); + if (question.multiple) { + return ( + <FormControlLabel + key={option.label} + disabled={!isEditable || isSubmitting} + control={ + <Checkbox + size="small" + checked={checked} + onChange={(event) => { + if (event.target.checked) { + setQuestionAnswers([...selectedAnswers, option.label]); + } else { + setQuestionAnswers( + selectedAnswers.filter((item) => item !== option.label), + ); + } + }} + /> + } + label={ + <Box> + <Typography variant="body2" fontWeight={750}> + {option.label} + </Typography> + {option.description ? ( + <Typography variant="caption" color="text.secondary"> + {option.description} + </Typography> + ) : null} + </Box> + } + sx={{ alignItems: "flex-start", m: 0 }} + /> + ); + } + return ( + <Button + key={option.label} + size="small" + variant={checked ? "contained" : "outlined"} + disabled={!isEditable || isSubmitting} + onClick={() => setQuestionAnswers([option.label])} + startIcon={ + checked ? ( + <CheckCircleRounded fontSize="small" /> + ) : ( + <RadioButtonUncheckedRounded fontSize="small" /> + ) + } + sx={{ + justifyContent: "flex-start", + minHeight: 38, + borderRadius: 2, + textTransform: "none", + fontWeight: 800, + bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45), + borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22), + "&:hover": { + bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08), + }, + }} + > + <Box sx={{ textAlign: "left", minWidth: 0 }}> + <Typography variant="body2" fontWeight={800}> + {option.label} + </Typography> + {option.description ? ( + <Typography + variant="caption" + sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }} + > + {option.description} + </Typography> + ) : null} + </Box> + </Button> + ); + })} + </Stack> + ) : null} + + {question.custom ? ( + <TextField + multiline + minRows={2} + maxRows={5} + fullWidth + size="small" + disabled={!isEditable || isSubmitting} + value={custom[index] ?? ""} + onChange={(event) => + setCustom((current) => ({ + ...current, + [index]: event.target.value, + })) + } + placeholder="补充说明" + sx={{ mt: 1 }} + /> + ) : null} + </Box> + ); + })} + + {questionRequest.status === "answered" ? ( + <Typography + variant="caption" + color="success.main" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha(theme.palette.success.main, 0.07), + wordBreak: "break-word", + }} + > + 已回答{answerSummary ? `:${answerSummary}` : ""} + </Typography> + ) : null} + + {questionRequest.status === "rejected" ? ( + <Typography + variant="caption" + color="text.secondary" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha("#000", 0.035), + }} + > + 已跳过 + </Typography> + ) : null} + + {questionRequest.error ? ( + <Typography + variant="caption" + color="error.main" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha(theme.palette.error.main, 0.06), + wordBreak: "break-word", + }} + > + {questionRequest.error} + </Typography> + ) : null} + </Stack> + + {isEditable || isSubmitting ? ( + <Stack + direction="row" + spacing={1} + flexWrap="wrap" + useFlexGap + sx={{ px: 1.5, pb: 1.35, pl: 1.75 }} + > + <Button + size="small" + variant="outlined" + disabled={isSubmitting} + onClick={() => onReject(questionRequest.requestId)} + sx={{ + height: 34, + borderRadius: "17px", + px: 1.5, + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + color: "text.secondary", + borderColor: alpha(theme.palette.text.secondary, 0.22), + bgcolor: alpha("#fff", 0.45), + }} + > + 跳过 + </Button> + <Button + size="small" + variant="contained" + disableElevation + disabled={!canSubmit || isSubmitting} + onClick={() => onReply(questionRequest.requestId, answers)} + startIcon={ + isSubmitting ? ( + <CircularProgress size={14} color="inherit" /> + ) : ( + <CheckCircleRounded fontSize="small" /> + ) + } + sx={{ + minWidth: 104, + height: 34, + borderRadius: "17px", + bgcolor: "#0288d1", + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`, + "&:hover": { + bgcolor: "#0277bd", + boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`, + }, + }} + > + 提交回答 + </Button> + </Stack> + ) : null} + </Box> + ); +}; + +const QuestionRequestGroup = ({ + questions, + onReply, + onReject, +}: { + questions: NonNullable<Message["questions"]>; + onReply: (requestId: string, answers: string[][]) => void; + onReject: (requestId: string) => void; +}) => ( + <Stack spacing={1}> + {questions.map((question) => ( + <QuestionRequestCard + key={question.requestId} + questionRequest={question} + onReply={onReply} + onReject={onReject} + /> + ))} + </Stack> +); + +const TodoPlanCard = ({ + todoUpdate, +}: { + todoUpdate: NonNullable<Message["todos"]>[number]; +}) => { + const theme = useTheme(); + const total = todoUpdate.todos.length; + const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; + const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); + const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; + const isAborted = cancelled > 0 && !running; + const [expanded, setExpanded] = React.useState( + !isAborted && todoUpdate.todos.length <= 3, + ); + React.useEffect(() => { + if (isAborted) { + setExpanded(false); + } + }, [isAborted]); + const visibleTodos = + isAborted && !expanded + ? [] + : expanded || total <= 3 + ? todoUpdate.todos + : [ + ...todoUpdate.todos.slice(0, 3), + ...(running && !todoUpdate.todos.slice(0, 3).some((todo) => todo.id === running.id) + ? [running] + : []), + ]; + + const getTodoVisual = (status: NonNullable<Message["todos"]>[number]["todos"][number]["status"]) => { + if (status === "completed") { + return { icon: <CheckCircleRounded sx={{ fontSize: 18 }} />, color: theme.palette.success.main, label: "已完成" }; + } + if (status === "in_progress") { + return { icon: <CircularProgress size={16} />, color: "#0288d1", label: "进行中" }; + } + if (status === "cancelled") { + return { icon: <BlockRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.disabled, label: "已中止" }; + } + return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.secondary, label: "待处理" }; + }; + + if (total === 0) { + return null; + } + + return ( + <Box + sx={{ + borderRadius: 3, + overflow: "hidden", + border: `1px solid ${alpha("#fff", 0.72)}`, + bgcolor: alpha("#fff", 0.48), + boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`, + backdropFilter: "blur(20px)", + }} + > + <Stack + direction="row" + alignItems="center" + spacing={1} + role="button" + tabIndex={0} + onClick={() => setExpanded((value) => !value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded((value) => !value); + } + }} + sx={{ + px: 1.5, + py: 1.15, + cursor: "pointer", + transition: "background-color 0.2s ease", + "&:hover": { bgcolor: alpha("#000", 0.025) }, + }} + > + <Box + sx={{ + width: 30, + height: 30, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: "#00838f", + bgcolor: alpha("#00838f", 0.1), + border: `1px solid ${alpha("#00838f", 0.15)}`, + }} + > + <AssignmentTurnedInRounded sx={{ fontSize: 18 }} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + 任务规划 + </Typography> + <Typography variant="caption" color="text.secondary"> + {isAborted + ? `${completed}/${total} 已完成,${cancelled} 项已中止` + : `${completed}/${total} 已完成${running ? ",1 项进行中" : ""}`} + </Typography> + </Box> + <IconButton + size="small" + aria-label={expanded ? "收起任务规划" : "展开任务规划"} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.035), + "&:hover": { bgcolor: alpha("#000", 0.07) }, + }} + > + {expanded ? ( + <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> + ) : ( + <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> + )} + </IconButton> + </Stack> + + {visibleTodos.length ? ( + <Stack spacing={0} sx={{ px: 1.5, pb: 1.2 }}> + {visibleTodos.map((todo, index) => { + const visual = getTodoVisual(todo.status); + return ( + <Stack + key={`${todo.id}-${index}`} + direction="row" + alignItems="center" + spacing={1} + sx={{ + py: 0.75, + borderTop: index === 0 ? `1px solid ${alpha("#000", 0.05)}` : "none", + color: todo.status === "cancelled" ? "text.disabled" : "text.primary", + }} + > + <Box + sx={{ + width: 24, + height: 24, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: visual.color, + bgcolor: alpha(visual.color, 0.08), + }} + > + {visual.icon} + </Box> + <Typography + variant="body2" + sx={{ + minWidth: 0, + flex: 1, + wordBreak: "break-word", + textDecoration: todo.status === "cancelled" ? "line-through" : undefined, + }} + > + {todo.content} + </Typography> + <Chip + size="small" + label={visual.label} + sx={{ + height: 22, + borderRadius: "11px", + fontSize: "0.68rem", + fontWeight: 800, + color: visual.color, + bgcolor: alpha(visual.color, 0.08), + "& .MuiChip-label": { px: 0.85 }, + }} + /> + </Stack> + ); + })} + </Stack> + ) : null} + </Box> + ); +}; + export const AgentTurn = React.memo( ({ message, @@ -682,6 +1281,8 @@ export const AgentTurn = React.memo( onRegenerate, onCreateBranch, onReplyPermission, + onReplyQuestion, + onRejectQuestion, }: AgentTurnProps) => { const theme = useTheme(); const isUser = message.role === "user"; @@ -824,6 +1425,18 @@ export const AgentTurn = React.memo( /> ) : null} + {message.questions?.length ? ( + <QuestionRequestGroup + questions={message.questions} + onReply={onReplyQuestion} + onReject={onRejectQuestion} + /> + ) : null} + + {message.todos?.length ? ( + <TodoPlanCard todoUpdate={message.todos[message.todos.length - 1]} /> + ) : null} + <Box sx={{ p: 1.5, diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index 20bc225..ac62e21 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -44,6 +44,8 @@ describe("AgentWorkspace", () => { onRegenerate: jest.fn(), onCreateBranch: jest.fn(), onReplyPermission: jest.fn(), + onReplyQuestion: jest.fn(), + onRejectQuestion: jest.fn(), }; beforeEach(() => { diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 37e415f..dc76f07 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -31,6 +31,8 @@ type AgentWorkspaceProps = { onRegenerate: (messageId: string) => void; onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; + onReplyQuestion: (requestId: string, answers: string[][]) => void; + onRejectQuestion: (requestId: string) => void; }; type TurnListProps = { @@ -45,6 +47,8 @@ type TurnListProps = { onRegenerate: (messageId: string) => void; onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; + onReplyQuestion: (requestId: string, answers: string[][]) => void; + onRejectQuestion: (requestId: string) => void; }; const sameMessages = (left: Message[], right: Message[]) => @@ -63,6 +67,8 @@ const TurnListInner = ({ onRegenerate, onCreateBranch, onReplyPermission, + onReplyQuestion, + onRejectQuestion, }: TurnListProps) => { return ( <> @@ -79,6 +85,8 @@ const TurnListInner = ({ onRegenerate={onRegenerate} onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} + onReplyQuestion={onReplyQuestion} + onRejectQuestion={onRejectQuestion} /> ))} </> @@ -98,7 +106,9 @@ const TurnList = React.memo( prevProps.isTtsSupported === nextProps.isTtsSupported && prevProps.onRegenerate === nextProps.onRegenerate && prevProps.onCreateBranch === nextProps.onCreateBranch && - prevProps.onReplyPermission === nextProps.onReplyPermission, + prevProps.onReplyPermission === nextProps.onReplyPermission && + prevProps.onReplyQuestion === nextProps.onReplyQuestion && + prevProps.onRejectQuestion === nextProps.onRejectQuestion, ); TurnList.displayName = "TurnList"; @@ -231,6 +241,8 @@ export const AgentWorkspace = ({ onRegenerate, onCreateBranch, onReplyPermission, + onReplyQuestion, + onRejectQuestion, }: AgentWorkspaceProps) => { const theme = useTheme(); const latestAssistant = [...messages] @@ -278,6 +290,8 @@ export const AgentWorkspace = ({ onRegenerate={onRegenerate} onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} + onReplyQuestion={onReplyQuestion} + onRejectQuestion={onRejectQuestion} /> {streamingMessage ? ( @@ -293,6 +307,8 @@ export const AgentWorkspace = ({ onRegenerate={onRegenerate} onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} + onReplyQuestion={onReplyQuestion} + onRejectQuestion={onRejectQuestion} /> ) : null} </Box> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index d1d1192..b73f62f 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -75,6 +75,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { createBranch, abort, replyPermission, + replyQuestion, + rejectQuestion, createSession, renameSession, removeSession, @@ -353,6 +355,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onRegenerate={regenerate} onCreateBranch={createBranch} onReplyPermission={replyPermission} + onReplyQuestion={replyQuestion} + onRejectQuestion={rejectQuestion} /> <AgentComposer diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 2ff19ff..8271380 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -1,3 +1,8 @@ +import type { + AgentQuestionRequest, + AgentTodoUpdate, +} from "@/lib/chatStream"; + export type ChatProgress = { id: string; phase: string; @@ -55,6 +60,8 @@ export type Message = { progress?: ChatProgress[]; artifacts?: AgentArtifact[]; permissions?: AgentPermissionRequest[]; + questions?: AgentQuestionRequest[]; + todos?: AgentTodoUpdate[]; }; export type Props = { diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 060dd1e..65b24e8 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -7,6 +7,7 @@ import { abortAgentChat, forkAgentChat, replyAgentPermission, + replyAgentQuestion, resumeAgentChatStream, streamAgentChat, } from "@/lib/chatStream"; @@ -16,6 +17,7 @@ jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), forkAgentChat: jest.fn(async () => "forked-session"), replyAgentPermission: jest.fn(async () => undefined), + replyAgentQuestion: jest.fn(async () => undefined), resumeAgentChatStream: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined), })); @@ -53,11 +55,13 @@ describe("useAgentChatSession", () => { jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); jest.mocked(replyAgentPermission).mockReset(); + jest.mocked(replyAgentQuestion).mockReset(); jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); + jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); deleteChatSession.mockImplementation(async () => undefined); @@ -333,6 +337,337 @@ describe("useAgentChatSession", () => { ]); }); + it("applies question responses to the message that owns the request", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "q-1", + sessionId: "session-loaded", + questions: [ + { + header: "范围", + question: "选择范围", + options: [], + custom: true, + }, + ], + createdAt: 123, + status: "pending", + }, + ], + }, + { id: "a2", role: "assistant", content: "后续消息" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_response", + sessionId: "session-loaded", + requestId: "q-1", + answers: [["城区"]], + rejected: false, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect(result.current.messages[1].questions?.[0]).toEqual( + expect.objectContaining({ + requestId: "q-1", + status: "answered", + answers: [["城区"]], + }), + ); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("deduplicates question requests across assistant messages", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "question-1", + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending", + }, + ], + }, + { id: "a2", role: "assistant", content: "后续消息" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_request", + sessionId: "session-loaded", + requestId: "call-1", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 456, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + const allQuestions = result.current.messages.flatMap( + (message) => message.questions ?? [], + ); + expect(allQuestions).toHaveLength(1); + expect(result.current.messages[1].questions?.[0]).toEqual( + expect.objectContaining({ + requestId: "question-1", + tool: expect.objectContaining({ callID: "call-1" }), + }), + ); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "question-1", + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending", + }, + ], + }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_request", + sessionId: "session-loaded", + requestId: "call-1", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 456, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + const allQuestions = result.current.messages.flatMap( + (message) => message.questions ?? [], + ); + expect(allQuestions).toHaveLength(1); + expect(allQuestions[0]).toEqual( + expect.objectContaining({ + requestId: "question-1", + tool: expect.objectContaining({ callID: "call-1" }), + }), + ); + }); + + it("deduplicates persisted duplicate questions from state events", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + const duplicateQuestion = { + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending" as const, + }; + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [{ ...duplicateQuestion, requestId: "question-1" }], + }, + { + id: "a2", + role: "assistant", + content: "后续消息", + questions: [{ ...duplicateQuestion, requestId: "call-1" }], + }, + ], + isStreaming: true, + runStatus: "running", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect( + result.current.messages.flatMap((message) => message.questions ?? []), + ).toHaveLength(1); + expect(result.current.messages[1].questions).toHaveLength(1); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + it("aborts a resumed streaming session through the backend abort endpoint", async () => { listChatSessions.mockResolvedValue([ { @@ -433,6 +768,23 @@ describe("useAgentChatSession", () => { title: "开始分析", startedAt: 1000, } satisfies StreamEvent); + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "分析水位", + status: "in_progress", + }, + { + id: "todo-2", + content: "生成建议", + status: "pending", + }, + ], + createdAt: 1001, + } satisfies StreamEvent); signal?.addEventListener("abort", () => { reject(new Error("aborted")); @@ -474,6 +826,22 @@ describe("useAgentChatSession", () => { endedAt: expect.any(Number), }), ], + todos: [ + expect.objectContaining({ + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "cancelled", + updatedAt: expect.any(Number), + }), + expect.objectContaining({ + id: "todo-2", + status: "cancelled", + updatedAt: expect.any(Number), + }), + ], + }), + ], }), ); expect(abortAgentChat).toHaveBeenCalledWith("session-1"); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 99dab87..19e0231 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -5,13 +5,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { abortAgentChat, forkAgentChat, + rejectAgentQuestion, replyAgentPermission, + replyAgentQuestion, resumeAgentChatStream, streamAgentChat, } from "@/lib/chatStream"; import type { AgentApprovalMode, AgentModel, + AgentQuestionRequest, + AgentTodoUpdate, PermissionReply, StreamEvent, } from "@/lib/chatStream"; @@ -135,6 +139,20 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) => }; }); +const cancelRunningTodos = (todos: AgentTodoUpdate[] | undefined) => + todos?.map((todoUpdate) => ({ + ...todoUpdate, + todos: todoUpdate.todos.map((todo) => + todo.status === "pending" || todo.status === "in_progress" + ? { + ...todo, + status: "cancelled" as const, + updatedAt: Date.now(), + } + : todo, + ), + })); + const upsertPermission = ( permissions: AgentPermissionRequest[] | undefined, event: StreamEvent & { type: "permission_request" }, @@ -170,12 +188,192 @@ const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["sta return "rejected"; }; +const isActionableQuestionRequest = (question: { + requestId: string; + tool?: AgentQuestionRequest["tool"]; +}) => Boolean(question.requestId && question.requestId !== question.tool?.callID); + +const toQuestionRequest = ( + event: StreamEvent & { type: "question_request" }, + status: AgentQuestionRequest["status"] = "pending", +): AgentQuestionRequest => ({ + requestId: event.requestId, + sessionId: event.sessionId, + questions: event.questions, + tool: event.tool, + createdAt: event.createdAt, + status, +}); + +const getQuestionContentSignature = ( + questions: AgentQuestionRequest["questions"], +) => + JSON.stringify( + questions.map((question) => ({ + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multiple: question.multiple ?? false, + custom: question.custom ?? false, + })), + ); + +const isSameQuestionRequest = ( + question: AgentQuestionRequest, + event: StreamEvent & { type: "question_request" }, +) => { + if (question.requestId === event.requestId) return true; + if (question.tool?.callID && event.tool?.callID) { + return question.tool.callID === event.tool.callID; + } + return ( + question.status === "pending" && + question.sessionId === event.sessionId && + getQuestionContentSignature(question.questions) === + getQuestionContentSignature(event.questions) + ); +}; + +const isSameQuestionPair = ( + left: AgentQuestionRequest, + right: AgentQuestionRequest, +) => { + if (left.requestId === right.requestId) return true; + if (left.tool?.callID && right.tool?.callID) { + return left.tool.callID === right.tool.callID; + } + return ( + left.status === "pending" && + right.status === "pending" && + left.sessionId === right.sessionId && + getQuestionContentSignature(left.questions) === + getQuestionContentSignature(right.questions) + ); +}; + +const dedupeQuestionsAcrossMessages = (messages: Message[]) => { + const seen: AgentQuestionRequest[] = []; + let changed = false; + const nextMessages = messages.map((message) => { + if (!message.questions?.length) { + return message; + } + const nextQuestions = message.questions.filter((question) => { + if (seen.some((existing) => isSameQuestionPair(existing, question))) { + changed = true; + return false; + } + seen.push(question); + return true; + }); + if (nextQuestions.length === message.questions.length) { + return message; + } + return { + ...message, + questions: nextQuestions.length ? nextQuestions : undefined, + }; + }); + return changed ? nextMessages : messages; +}; + +const upsertQuestionAcrossMessages = ( + messages: Message[], + event: StreamEvent & { type: "question_request" }, + assistantMessageId: string, +) => { + let existing: AgentQuestionRequest | undefined; + for (const message of messages) { + const match = message.questions?.find((question) => + isSameQuestionRequest(question, event), + ); + if (match) { + existing = match; + break; + } + } + + const existingStatus: AgentQuestionRequest["status"] | undefined = + existing?.status === "submitting" ? "submitting" : undefined; + const nextQuestion = + existing && + isActionableQuestionRequest(existing) && + !isActionableQuestionRequest(event) + ? { + ...existing, + sessionId: event.sessionId, + questions: event.questions, + tool: event.tool ?? existing.tool, + createdAt: event.createdAt, + status: existingStatus ?? existing.status, + } + : toQuestionRequest(event, existingStatus ?? "pending"); + const targetMessageId = existing + ? messages.find((message) => + message.questions?.some((question) => isSameQuestionRequest(question, event)), + )?.id ?? assistantMessageId + : assistantMessageId; + + return messages.map((message) => { + const filteredQuestions = message.questions?.filter( + (question) => !isSameQuestionRequest(question, event), + ); + if (message.id !== targetMessageId) { + return filteredQuestions?.length === message.questions?.length + ? message + : { + ...message, + questions: filteredQuestions?.length ? filteredQuestions : undefined, + }; + } + + const nextQuestions = [...(filteredQuestions ?? []), nextQuestion]; + return { + ...message, + questions: nextQuestions, + }; + }); +}; + +const applyQuestionResponse = ( + questions: AgentQuestionRequest[] | undefined, + event: StreamEvent & { type: "question_response" }, +) => + (questions ?? []).map((question) => + question.requestId === event.requestId + ? { + ...question, + status: event.rejected ? "rejected" as const : "answered" as const, + answers: event.answers ?? question.answers, + repliedAt: Date.now(), + error: undefined, + } + : question, + ); + +const upsertTodoUpdate = ( + todos: AgentTodoUpdate[] | undefined, + event: StreamEvent & { type: "todo_update" }, +) => [ + { + sessionId: event.sessionId, + messageId: event.messageId, + todos: event.todos, + createdAt: event.createdAt, + }, +]; + const finalizeAssistantMessageAfterAbort = (message: Message): Message => { const completedProgress = completeRunningProgress(message.progress); + const cancelledTodos = cancelRunningTodos(message.todos); const hasVisibleOutput = message.content.trim().length > 0 || Boolean(message.artifacts?.length) || - Boolean(completedProgress?.length); + Boolean(completedProgress?.length) || + Boolean(cancelledTodos?.length); if (!hasVisibleOutput) { return message; @@ -186,6 +384,7 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => { content: message.content || "⚠️ **请求已中断**", isError: true, progress: completedProgress, + todos: cancelledTodos, }; }; @@ -291,7 +490,7 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - setMessages(loadedState.messages); + setMessages(dedupeQuestionsAcrossMessages(loadedState.messages)); setSessionTitle(loadedState.title); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setSessionId(loadedState.sessionId); @@ -401,7 +600,9 @@ export const useAgentChatSession = ({ } if (event.type === "state") { - const nextMessages = cloneMessages(event.messages as Message[]); + const nextMessages = dedupeQuestionsAcrossMessages( + cloneMessages(event.messages as Message[]), + ); messagesRef.current = nextMessages; setMessages(nextMessages); setIsStreaming(event.isStreaming); @@ -502,6 +703,32 @@ export const useAgentChatSession = ({ }; }), ); + } else if (event.type === "question_request") { + setMessages((prev) => + upsertQuestionAcrossMessages(prev, event, assistantMessageId), + ); + } else if (event.type === "question_response") { + setMessages((prev) => + prev.map((message) => + message.questions?.some((question) => question.requestId === event.requestId) + ? { + ...message, + questions: applyQuestionResponse(message.questions, event), + } + : message, + ), + ); + } else if (event.type === "todo_update") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + todos: upsertTodoUpdate(message.todos, event), + } + : message, + ), + ); } else if (event.type === "done") { setMessages((prev) => prev.map((message) => { @@ -531,6 +758,7 @@ export const useAgentChatSession = ({ content: message.content || `⚠️ **错误:** ${event.message}`, isError: true, progress: completeRunningProgress(message.progress), + todos: cancelRunningTodos(message.todos), } : message, ), @@ -621,11 +849,7 @@ export const useAgentChatSession = ({ prev .map((message) => message.id === nextAssistantMessage.id - ? { - ...message, - content: message.content || "⚠️ **请求已中断**", - isError: true, - } + ? finalizeAssistantMessageAfterAbort(message) : message, ) .filter( @@ -635,7 +859,8 @@ export const useAgentChatSession = ({ message.role === "assistant" && message.content.trim().length === 0 && !(message.artifacts?.length) && - !(message.progress?.length) + !(message.progress?.length) && + !(message.todos?.length) ), ), ); @@ -766,6 +991,145 @@ export const useAgentChatSession = ({ [], ); + const replyQuestion = useCallback( + async (requestId: string, answers: string[][]) => { + const target = messagesRef.current + .flatMap((message) => message.questions ?? []) + .find((question) => question.requestId === requestId); + if (!target || target.status === "submitting") { + return; + } + + setMessages((prev) => + prev.map((message) => + !message.questions?.some((question) => question.requestId === requestId) + ? message + : { + ...message, + questions: message.questions.map((question) => + question.requestId === requestId + ? { ...question, status: "submitting", error: undefined } + : question, + ), + }, + ), + ); + + try { + await replyAgentQuestion(target.sessionId, requestId, answers); + setMessages((prev) => + prev.map((message) => + !message.questions?.some((question) => question.requestId === requestId) + ? message + : { + ...message, + questions: message.questions.map((question) => + question.requestId === requestId + ? { + ...question, + status: "answered", + answers, + repliedAt: Date.now(), + error: undefined, + } + : question, + ), + }, + ), + ); + } catch (error) { + setMessages((prev) => + prev.map((message) => + !message.questions?.some((question) => question.requestId === requestId) + ? message + : { + ...message, + questions: message.questions.map((question) => + question.requestId === requestId + ? { + ...question, + status: "error", + error: error instanceof Error ? error.message : String(error), + } + : question, + ), + }, + ), + ); + } + }, + [], + ); + + const rejectQuestion = useCallback( + async (requestId: string) => { + const target = messagesRef.current + .flatMap((message) => message.questions ?? []) + .find((question) => question.requestId === requestId); + if (!target || target.status === "submitting") { + return; + } + + setMessages((prev) => + prev.map((message) => + !message.questions?.some((question) => question.requestId === requestId) + ? message + : { + ...message, + questions: message.questions.map((question) => + question.requestId === requestId + ? { ...question, status: "submitting", error: undefined } + : question, + ), + }, + ), + ); + + try { + await rejectAgentQuestion(target.sessionId, requestId); + setMessages((prev) => + prev.map((message) => + !message.questions?.some((question) => question.requestId === requestId) + ? message + : { + ...message, + questions: message.questions.map((question) => + question.requestId === requestId + ? { + ...question, + status: "rejected", + repliedAt: Date.now(), + error: undefined, + } + : question, + ), + }, + ), + ); + } catch (error) { + setMessages((prev) => + prev.map((message) => + !message.questions?.some((question) => question.requestId === requestId) + ? message + : { + ...message, + questions: message.questions.map((question) => + question.requestId === requestId + ? { + ...question, + status: "error", + error: error instanceof Error ? error.message : String(error), + } + : question, + ), + }, + ), + ); + } + }, + [], + ); + const createSession = useCallback(() => { if (isHydrating || isStreaming) return; @@ -1009,6 +1373,8 @@ export const useAgentChatSession = ({ createBranch, abort, replyPermission, + replyQuestion, + rejectQuestion, createSession, renameSession, removeSession, diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 5477c02..fc71e5c 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -1,7 +1,9 @@ import { abortAgentChat, forkAgentChat, + rejectAgentQuestion, replyAgentPermission, + replyAgentQuestion, type StreamEvent, resumeAgentChatStream, streamAgentChat, @@ -218,6 +220,69 @@ describe("streamAgentChat", () => { ]); }); + it("parses question request, response, and todo update events", async () => { + apiFetch.mockResolvedValue({ + ok: true, + body: makeStream([ + 'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n', + 'event: question_response\ndata: {"session_id":"s1","request_id":"q-1","answers":[["城区","补充说明"]]}\n\n', + 'event: todo_update\ndata: {"session_id":"s1","todos":[{"id":"t1","content":"分析水位","status":"in_progress","priority":"high","updated_at":456}],"created_at":456}\n\n', + ]), + }); + + const events: StreamEvent[] = []; + + await streamAgentChat({ + message: "hi", + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([ + { + type: "question_request", + sessionId: "s1", + requestId: "q-1", + questions: [ + { + header: "范围", + question: "选择范围", + options: [{ label: "城区", description: "中心城区" }], + multiple: false, + custom: true, + }, + ], + tool: { + messageID: "m1", + callID: "c1", + }, + createdAt: 123, + }, + { + type: "question_response", + sessionId: "s1", + requestId: "q-1", + answers: [["城区", "补充说明"]], + rejected: false, + }, + { + type: "todo_update", + sessionId: "s1", + messageId: undefined, + todos: [ + { + id: "t1", + content: "分析水位", + status: "in_progress", + priority: "high", + createdAt: undefined, + updatedAt: 456, + }, + ], + createdAt: 456, + }, + ]); + }); + it("emits error when response is not ok", async () => { apiFetch.mockResolvedValue({ ok: false, @@ -314,6 +379,41 @@ describe("streamAgentChat", () => { ); }); + it("calls question reply and reject endpoints", async () => { + apiFetch.mockResolvedValue({ + ok: true, + status: 202, + text: async () => "", + }); + + await replyAgentQuestion("s1", "q-1", [["城区"]]); + await rejectAgentQuestion("s1", "q-2"); + + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"), + expect.objectContaining({ + method: "POST", + projectHeaderMode: "include", + skipAuthRedirect: true, + body: JSON.stringify({ + session_id: "s1", + answers: [["城区"]], + }), + }), + ); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"), + expect.objectContaining({ + method: "POST", + projectHeaderMode: "include", + skipAuthRedirect: true, + body: JSON.stringify({ + session_id: "s1", + }), + }), + ); + }); + it("calls fork endpoint and returns new session id", async () => { apiFetch.mockResolvedValue({ ok: true, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 88f6948..b5c1e37 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -8,6 +8,53 @@ export type AgentModel = export type PermissionReply = "once" | "always" | "reject"; export type AgentApprovalMode = "request" | "always"; +export type AgentQuestionStatus = + | "pending" + | "submitting" + | "answered" + | "rejected" + | "error"; + +export type AgentQuestionRequest = { + requestId: string; + sessionId: string; + questions: Array<{ + header: string; + question: string; + options: Array<{ + label: string; + description: string; + }>; + multiple?: boolean; + custom?: boolean; + }>; + tool?: { + messageID: string; + callID: string; + }; + createdAt: number; + repliedAt?: number; + status: AgentQuestionStatus; + answers?: string[][]; + error?: string; +}; + +export type AgentTodoItem = { + id: string; + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + priority?: "low" | "medium" | "high"; + createdAt?: number; + updatedAt?: number; +}; + +export type AgentTodoUpdate = { + sessionId: string; + messageId?: string; + todos: AgentTodoItem[]; + createdAt: number; +}; + export type StreamEvent = | { type: "state"; @@ -64,6 +111,28 @@ export type StreamEvent = sessionId: string; requestId: string; reply: PermissionReply; + } + | { + type: "question_request"; + sessionId: string; + requestId: string; + questions: AgentQuestionRequest["questions"]; + tool?: AgentQuestionRequest["tool"]; + createdAt: number; + } + | { + type: "question_response"; + sessionId: string; + requestId: string; + answers?: string[][]; + rejected?: boolean; + } + | { + type: "todo_update"; + sessionId: string; + messageId?: string; + todos: AgentTodoItem[]; + createdAt: number; }; type StreamOptions = { @@ -125,6 +194,80 @@ const resolveToolParams = ( return isObjectRecord(params) ? params : {}; }; +const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => { + if (!Array.isArray(value)) return []; + return value + .filter(isObjectRecord) + .map((question) => ({ + header: typeof question.header === "string" ? question.header : "", + question: typeof question.question === "string" ? question.question : "", + options: Array.isArray(question.options) + ? question.options.filter(isObjectRecord).map((option) => ({ + label: typeof option.label === "string" ? option.label : "", + description: + typeof option.description === "string" ? option.description : "", + })) + : [], + multiple: typeof question.multiple === "boolean" ? question.multiple : undefined, + custom: typeof question.custom === "boolean" ? question.custom : undefined, + })); +}; + +const normalizeAnswers = (value: unknown): string[][] | undefined => { + if (!Array.isArray(value)) return undefined; + return value.map((answer) => + Array.isArray(answer) + ? answer.filter((item): item is string => typeof item === "string") + : [], + ); +}; + +const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => { + if (!isObjectRecord(value)) return undefined; + const messageID = + typeof value.messageID === "string" + ? value.messageID + : typeof value.message_id === "string" + ? value.message_id + : undefined; + const callID = + typeof value.callID === "string" + ? value.callID + : typeof value.call_id === "string" + ? value.call_id + : undefined; + return messageID && callID ? { messageID, callID } : undefined; +}; + +const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => { + if (value === "in_progress" || value === "completed" || value === "cancelled") { + return value; + } + return "pending"; +}; + +const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => { + if (value === "low" || value === "medium" || value === "high") { + return value; + } + return undefined; +}; + +const normalizeTodos = (value: unknown): AgentTodoItem[] => { + if (!Array.isArray(value)) return []; + return value.filter(isObjectRecord).map((todo, index) => ({ + id: + typeof todo.id === "string" && todo.id.trim() + ? todo.id + : `todo-${index}`, + content: typeof todo.content === "string" ? todo.content : "", + status: normalizeTodoStatus(todo.status), + priority: normalizeTodoPriority(todo.priority), + createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined, + updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined, + })); +}; + const emitParsedStreamEvent = ( event: string, data: string, @@ -158,6 +301,11 @@ const emitParsedStreamEvent = ( always?: unknown; created_at?: number; reply?: PermissionReply; + questions?: unknown; + answers?: unknown; + rejected?: boolean; + message_id?: string; + todos?: unknown; }; if (event === "state") { onEvent({ @@ -244,6 +392,31 @@ const emitParsedStreamEvent = ( requestId: parsed.request_id ?? "", reply: parsed.reply ?? "reject", }); + } else if (event === "question_request") { + onEvent({ + type: "question_request", + sessionId: parsed.session_id ?? "", + requestId: parsed.request_id ?? "", + questions: normalizeQuestionList(parsed.questions), + tool: normalizeQuestionTool(parsed.tool), + createdAt: parsed.created_at ?? Date.now(), + }); + } else if (event === "question_response") { + onEvent({ + type: "question_response", + sessionId: parsed.session_id ?? "", + requestId: parsed.request_id ?? "", + answers: normalizeAnswers(parsed.answers), + rejected: parsed.rejected === true, + }); + } else if (event === "todo_update") { + onEvent({ + type: "todo_update", + sessionId: parsed.session_id ?? "", + messageId: parsed.message_id, + todos: normalizeTodos(parsed.todos), + createdAt: parsed.created_at ?? Date.now(), + }); } } catch { onEvent({ @@ -443,6 +616,60 @@ export const replyAgentPermission = async ( } }; +export const replyAgentQuestion = async ( + sessionId: string, + requestId: string, + answers: string[][], +) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: sessionId, + answers, + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `question reply failed: ${response.status}`); + } +}; + +export const rejectAgentQuestion = async ( + sessionId: string, + requestId: string, +) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: sessionId, + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `question reject failed: ${response.status}`); + } +}; + export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, { method: "POST", -- 2.54.0 From 3a36c693cdfb58ac9c7296605ecd594c53fa0af5 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 18:39:45 +0800 Subject: [PATCH 169/281] fix(chat): update question abort state --- src/components/chat/AgentTurn.tsx | 187 +++++++++++++++--- .../chat/hooks/useAgentChatSession.test.tsx | 39 ++++ .../chat/hooks/useAgentChatSession.ts | 56 +++++- 3 files changed, 255 insertions(+), 27 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 7990d0f..b9bd534 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -51,6 +51,7 @@ import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import BlockRounded from "@mui/icons-material/BlockRounded"; import PushPinRounded from "@mui/icons-material/PushPinRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; @@ -713,26 +714,30 @@ const QuestionRequestCard = ({ const isSubmitting = questionRequest.status === "submitting"; const statusColor = getQuestionStatusColor(questionRequest.status, theme); const [selected, setSelected] = React.useState<Record<number, string[]>>({}); + const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({}); const [custom, setCustom] = React.useState<Record<number, string>>({}); const answers = React.useMemo( () => questionRequest.questions.map((question, index) => { const selectedAnswers = selected[index] ?? []; + const isCustomSelected = + customSelected[index] === true || + (question.custom !== false && question.options.length === 0); const customAnswer = custom[index]?.trim(); - return customAnswer ? [...selectedAnswers, customAnswer] : selectedAnswers; + return isCustomSelected && customAnswer + ? [...selectedAnswers, customAnswer] + : selectedAnswers; }), - [custom, questionRequest.questions, selected], + [custom, customSelected, questionRequest.questions, selected], ); const canSubmit = isEditable && questionRequest.questions.length > 0 && - questionRequest.questions.every((question, index) => { + questionRequest.questions.every((_, index) => { const answer = answers[index] ?? []; - const hasInput = answer.some((item) => item.trim().length > 0); - const canAnswer = question.options.length > 0 || question.custom === true; - return canAnswer && hasInput; + return answer.some((item) => item.trim().length > 0); }); const answerSummary = (questionRequest.answers ?? []) @@ -809,12 +814,22 @@ const QuestionRequestCard = ({ <Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}> {questionRequest.questions.map((question, index) => { const selectedAnswers = selected[index] ?? []; + const isCustomEnabled = question.custom !== false; + const isCustomSelected = + customSelected[index] === true || + (isCustomEnabled && question.options.length === 0); const setQuestionAnswers = (nextAnswers: string[]) => { setSelected((current) => ({ ...current, [index]: nextAnswers, })); }; + const setQuestionCustomSelected = (checked: boolean) => { + setCustomSelected((current) => ({ + ...current, + [index]: checked, + })); + }; return ( <Box @@ -884,7 +899,10 @@ const QuestionRequestCard = ({ size="small" variant={checked ? "contained" : "outlined"} disabled={!isEditable || isSubmitting} - onClick={() => setQuestionAnswers([option.label])} + onClick={() => { + setQuestionAnswers([option.label]); + setQuestionCustomSelected(false); + }} startIcon={ checked ? ( <CheckCircleRounded fontSize="small" /> @@ -921,28 +939,145 @@ const QuestionRequestCard = ({ </Button> ); })} + {isCustomEnabled ? ( + question.multiple ? ( + <FormControlLabel + disabled={!isEditable || isSubmitting} + control={ + <Checkbox + size="small" + checked={isCustomSelected} + onChange={(event) => + setQuestionCustomSelected(event.target.checked) + } + sx={{ + p: 0.5, + color: alpha("#0288d1", 0.55), + "&.Mui-checked": { color: "#0288d1" }, + }} + /> + } + label={ + <Stack direction="row" spacing={0.75} alignItems="center"> + <EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} /> + <Typography variant="body2" fontWeight={800}> + 自定义回答 + </Typography> + </Stack> + } + sx={{ + alignItems: "center", + minHeight: 38, + m: 0, + px: 0.75, + py: 0.25, + borderRadius: 2, + border: `1px solid ${ + isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18) + }`, + bgcolor: isCustomSelected + ? alpha("#0288d1", 0.1) + : alpha("#fff", 0.45), + transition: "background-color 0.18s ease, border-color 0.18s ease", + "&:hover": { + bgcolor: isCustomSelected + ? alpha("#0288d1", 0.13) + : alpha("#0288d1", 0.07), + }, + "& .MuiFormControlLabel-label": { + color: isCustomSelected ? "#0277bd" : "text.primary", + }, + }} + /> + ) : ( + <Button + size="small" + variant={isCustomSelected ? "contained" : "outlined"} + disabled={!isEditable || isSubmitting} + onClick={() => { + setQuestionAnswers([]); + setQuestionCustomSelected(true); + }} + startIcon={ + isCustomSelected ? ( + <CheckCircleRounded fontSize="small" /> + ) : ( + <EditNoteRounded fontSize="small" /> + ) + } + sx={{ + justifyContent: "flex-start", + minHeight: 38, + borderRadius: 2, + textTransform: "none", + fontWeight: 800, + bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45), + borderColor: isCustomSelected + ? "#0288d1" + : alpha("#0288d1", 0.22), + "&:hover": { + bgcolor: isCustomSelected + ? "#0277bd" + : alpha("#0288d1", 0.08), + }, + }} + > + <Box sx={{ textAlign: "left", minWidth: 0 }}> + <Typography variant="body2" fontWeight={800}> + 自定义回答 + </Typography> + </Box> + </Button> + ) + ) : null} </Stack> ) : null} - {question.custom ? ( - <TextField - multiline - minRows={2} - maxRows={5} - fullWidth - size="small" - disabled={!isEditable || isSubmitting} - value={custom[index] ?? ""} - onChange={(event) => - setCustom((current) => ({ - ...current, - [index]: event.target.value, - })) - } - placeholder="补充说明" - sx={{ mt: 1 }} - /> - ) : null} + <Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit> + <Box + sx={{ + mt: 0.85, + px: 1.15, + py: 0.85, + borderRadius: 2.5, + bgcolor: alpha("#fff", 0.62), + border: `1px solid ${alpha("#fff", 0.82)}`, + boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`, + backdropFilter: "blur(18px)", + }} + > + <TextField + multiline + minRows={2} + maxRows={5} + fullWidth + variant="standard" + disabled={!isEditable || isSubmitting} + value={custom[index] ?? ""} + onChange={(event) => + setCustom((current) => ({ + ...current, + [index]: event.target.value, + })) + } + placeholder="输入自定义回答" + InputProps={{ + disableUnderline: true, + sx: { + alignItems: "flex-start", + fontSize: "0.88rem", + lineHeight: 1.55, + fontWeight: 500, + color: "text.primary", + "& textarea::placeholder": { + color: alpha(theme.palette.text.primary, 0.38), + opacity: 1, + }, + }, + }} + /> + </Box> + </Collapse> </Box> ); })} diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 65b24e8..7948242 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -785,6 +785,29 @@ describe("useAgentChatSession", () => { ], createdAt: 1001, } satisfies StreamEvent); + onEvent({ + type: "permission_request", + sessionId: "session-1", + requestId: "perm-abort", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + always: ["npm test"], + createdAt: 1002, + } satisfies StreamEvent); + onEvent({ + type: "question_request", + sessionId: "session-1", + requestId: "question-abort", + questions: [ + { + header: "范围", + question: "请选择范围", + options: [{ label: "城区", description: "中心城区" }], + }, + ], + createdAt: 1003, + } satisfies StreamEvent); signal?.addEventListener("abort", () => { reject(new Error("aborted")); @@ -842,6 +865,22 @@ describe("useAgentChatSession", () => { ], }), ], + permissions: [ + expect.objectContaining({ + requestId: "perm-abort", + status: "rejected", + repliedAt: expect.any(Number), + error: undefined, + }), + ], + questions: [ + expect.objectContaining({ + requestId: "question-abort", + status: "rejected", + repliedAt: expect.any(Number), + error: undefined, + }), + ], }), ); expect(abortAgentChat).toHaveBeenCalledWith("session-1"); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 19e0231..b49bcef 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -217,7 +217,7 @@ const getQuestionContentSignature = ( description: option.description, })), multiple: question.multiple ?? false, - custom: question.custom ?? false, + custom: question.custom !== false, })), ); @@ -366,12 +366,64 @@ const upsertTodoUpdate = ( }, ]; +const rejectOpenPermissionsAfterAbort = ( + permissions: AgentPermissionRequest[] | undefined, +) => { + if (!permissions?.length) return permissions; + let changed = false; + const nextPermissions = permissions.map((permission) => { + if ( + permission.status !== "pending" && + permission.status !== "submitting" && + permission.status !== "error" + ) { + return permission; + } + changed = true; + return { + ...permission, + status: "rejected" as const, + repliedAt: Date.now(), + error: undefined, + }; + }); + return changed ? nextPermissions : permissions; +}; + +const rejectOpenQuestionsAfterAbort = ( + questions: AgentQuestionRequest[] | undefined, +) => { + if (!questions?.length) return questions; + let changed = false; + const nextQuestions = questions.map((question) => { + if ( + question.status !== "pending" && + question.status !== "submitting" && + question.status !== "error" + ) { + return question; + } + changed = true; + return { + ...question, + status: "rejected" as const, + repliedAt: Date.now(), + error: undefined, + }; + }); + return changed ? nextQuestions : questions; +}; + const finalizeAssistantMessageAfterAbort = (message: Message): Message => { const completedProgress = completeRunningProgress(message.progress); const cancelledTodos = cancelRunningTodos(message.todos); + const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions); + const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); const hasVisibleOutput = message.content.trim().length > 0 || Boolean(message.artifacts?.length) || + Boolean(rejectedPermissions?.length) || + Boolean(rejectedQuestions?.length) || Boolean(completedProgress?.length) || Boolean(cancelledTodos?.length); @@ -384,6 +436,8 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => { content: message.content || "⚠️ **请求已中断**", isError: true, progress: completedProgress, + permissions: rejectedPermissions, + questions: rejectedQuestions, todos: cancelledTodos, }; }; -- 2.54.0 From 865e425748d25abc0b44e51fdc90c6d77269928a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 19:14:30 +0800 Subject: [PATCH 170/281] feat(chat): refine shared todo card --- src/components/chat/AgentTurn.tsx | 365 +++++++++++------- src/components/chat/GlobalChatbox.types.ts | 2 +- .../chat/hooks/useAgentChatSession.test.tsx | 118 +++++- .../chat/hooks/useAgentChatSession.ts | 118 ++++-- 4 files changed, 417 insertions(+), 186 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index b9bd534..0597475 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -1220,45 +1220,142 @@ const QuestionRequestGroup = ({ const TodoPlanCard = ({ todoUpdate, }: { - todoUpdate: NonNullable<Message["todos"]>[number]; + todoUpdate: NonNullable<Message["todos"]>; }) => { const theme = useTheme(); const total = todoUpdate.todos.length; const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; - const isAborted = cancelled > 0 && !running; - const [expanded, setExpanded] = React.useState( - !isAborted && todoUpdate.todos.length <= 3, + const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length; + const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + const isAborted = cancelled > 0 && completed + cancelled === total; + const canCollapse = total > 4; + const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted); + const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos; + const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : []; + const hiddenCount = expanded ? 0 : collapsibleTodos.length; + const latestUpdatedAt = Math.max( + todoUpdate.createdAt, + ...todoUpdate.todos + .map((todo) => todo.updatedAt ?? todo.createdAt ?? 0) + .filter((value) => value > 0), ); - React.useEffect(() => { - if (isAborted) { - setExpanded(false); - } - }, [isAborted]); - const visibleTodos = - isAborted && !expanded - ? [] - : expanded || total <= 3 - ? todoUpdate.todos - : [ - ...todoUpdate.todos.slice(0, 3), - ...(running && !todoUpdate.todos.slice(0, 3).some((todo) => todo.id === running.id) - ? [running] - : []), - ]; + const updatedAtLabel = + latestUpdatedAt > 0 + ? new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(latestUpdatedAt)) + : undefined; - const getTodoVisual = (status: NonNullable<Message["todos"]>[number]["todos"][number]["status"]) => { + const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => { if (status === "completed") { - return { icon: <CheckCircleRounded sx={{ fontSize: 18 }} />, color: theme.palette.success.main, label: "已完成" }; + return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" }; } if (status === "in_progress") { - return { icon: <CircularProgress size={16} />, color: "#0288d1", label: "进行中" }; + return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" }; } if (status === "cancelled") { - return { icon: <BlockRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.disabled, label: "已中止" }; + return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" }; } - return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.secondary, label: "待处理" }; + return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" }; + }; + + const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => { + if (priority === "high") return { label: "高优先级", color: "#8a5a00" }; + if (priority === "medium") return { label: "中优先级", color: "#9a6a16" }; + if (priority === "low") return { label: "低优先级", color: "#8d7960" }; + return undefined; + }; + + const statusSummary = isAborted + ? `${completed} 完成 / ${cancelled} 中止` + : [ + completed ? `${completed} 完成` : null, + running ? "1 进行中" : null, + pending ? `${pending} 待办` : null, + cancelled ? `${cancelled} 中止` : null, + ].filter(Boolean).join(" / ") || "等待任务"; + const renderTodoRow = ( + todo: NonNullable<Message["todos"]>["todos"][number], + index: number, + ) => { + const visual = getTodoVisual(todo.status); + const priority = getPriorityLabel(todo.priority); + return ( + <Stack + key={`${todo.id}-${index}`} + direction="row" + alignItems="flex-start" + spacing={1} + sx={{ + py: 0.8, + borderTop: `1px solid ${alpha("#00838f", 0.08)}`, + color: todo.status === "cancelled" ? "text.disabled" : "text.primary", + }} + > + <Box + sx={{ + width: 24, + height: 24, + borderRadius: 1.25, + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: visual.color, + bgcolor: alpha(visual.color, 0.08), + mt: 0.1, + }} + > + {visual.icon} + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography + variant="body2" + sx={{ + minWidth: 0, + wordBreak: "break-word", + lineHeight: 1.45, + textDecoration: todo.status === "cancelled" ? "line-through" : undefined, + }} + > + {todo.content} + </Typography> + </Box> + <Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}> + {priority ? ( + <Chip + size="small" + label={priority.label} + sx={{ + height: 22, + borderRadius: "11px", + fontSize: "0.66rem", + fontWeight: 800, + color: priority.color, + bgcolor: alpha(priority.color, 0.045), + border: `1px solid ${alpha(priority.color, 0.16)}`, + "& .MuiChip-label": { px: 0.75 }, + }} + /> + ) : null} + <Chip + size="small" + label={visual.label} + sx={{ + height: 22, + borderRadius: "11px", + fontSize: "0.66rem", + fontWeight: 800, + color: visual.color, + bgcolor: alpha(visual.color, 0.08), + "& .MuiChip-label": { px: 0.75 }, + }} + /> + </Stack> + </Stack> + ); }; if (total === 0) { @@ -1268,138 +1365,138 @@ const TodoPlanCard = ({ return ( <Box sx={{ - borderRadius: 3, + borderRadius: 2, overflow: "hidden", - border: `1px solid ${alpha("#fff", 0.72)}`, - bgcolor: alpha("#fff", 0.48), - boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`, - backdropFilter: "blur(20px)", + border: `1px solid ${alpha("#00838f", 0.16)}`, + bgcolor: alpha("#f8fbfc", 0.82), }} > <Stack - direction="row" - alignItems="center" spacing={1} role="button" tabIndex={0} - onClick={() => setExpanded((value) => !value)} + onClick={() => { + if (canCollapse) { + setExpanded((value) => !value); + } + }} onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { + if (canCollapse && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); setExpanded((value) => !value); } }} sx={{ - px: 1.5, + px: 1.4, py: 1.15, - cursor: "pointer", + cursor: canCollapse ? "pointer" : "default", transition: "background-color 0.2s ease", - "&:hover": { bgcolor: alpha("#000", 0.025) }, + "&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined, }} > + <Stack direction="row" alignItems="center" spacing={1}> + <Box + sx={{ + width: 28, + height: 28, + borderRadius: 1.5, + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: "#00838f", + bgcolor: alpha("#00838f", 0.1), + border: `1px solid ${alpha("#00838f", 0.14)}`, + }} + > + <AssignmentTurnedInRounded sx={{ fontSize: 18 }} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Stack direction="row" alignItems="center" spacing={0.75}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + 会话任务 + </Typography> + <Chip + size="small" + label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"} + sx={{ + height: 20, + borderRadius: "10px", + fontSize: "0.66rem", + fontWeight: 800, + color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f", + bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08), + "& .MuiChip-label": { px: 0.75 }, + }} + /> + </Stack> + <Typography variant="caption" color="text.secondary"> + {statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""} + </Typography> + </Box> + {canCollapse ? ( + <IconButton + size="small" + aria-label={expanded ? "收起会话任务" : "展开会话任务"} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.035), + "&:hover": { bgcolor: alpha("#000", 0.07) }, + }} + > + {expanded ? ( + <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> + ) : ( + <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> + )} + </IconButton> + ) : null} + </Stack> <Box sx={{ - width: 30, - height: 30, - borderRadius: "50%", - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: "#00838f", + height: 6, + borderRadius: 999, + overflow: "hidden", bgcolor: alpha("#00838f", 0.1), - border: `1px solid ${alpha("#00838f", 0.15)}`, }} > - <AssignmentTurnedInRounded sx={{ fontSize: 18 }} /> + <Box + sx={{ + width: `${progress}%`, + height: "100%", + borderRadius: 999, + bgcolor: isAborted ? theme.palette.text.disabled : "#00838f", + transition: "width 0.25s ease", + }} + /> </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> - 任务规划 - </Typography> - <Typography variant="caption" color="text.secondary"> - {isAborted - ? `${completed}/${total} 已完成,${cancelled} 项已中止` - : `${completed}/${total} 已完成${running ? ",1 项进行中" : ""}`} - </Typography> - </Box> - <IconButton - size="small" - aria-label={expanded ? "收起任务规划" : "展开任务规划"} - sx={{ - width: 28, - height: 28, - color: "text.secondary", - bgcolor: alpha("#000", 0.035), - "&:hover": { bgcolor: alpha("#000", 0.07) }, - }} - > - {expanded ? ( - <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> - ) : ( - <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> - )} - </IconButton> </Stack> - {visibleTodos.length ? ( - <Stack spacing={0} sx={{ px: 1.5, pb: 1.2 }}> - {visibleTodos.map((todo, index) => { - const visual = getTodoVisual(todo.status); - return ( - <Stack - key={`${todo.id}-${index}`} - direction="row" - alignItems="center" - spacing={1} - sx={{ - py: 0.75, - borderTop: index === 0 ? `1px solid ${alpha("#000", 0.05)}` : "none", - color: todo.status === "cancelled" ? "text.disabled" : "text.primary", - }} - > - <Box - sx={{ - width: 24, - height: 24, - borderRadius: "50%", - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: visual.color, - bgcolor: alpha(visual.color, 0.08), - }} - > - {visual.icon} - </Box> - <Typography - variant="body2" - sx={{ - minWidth: 0, - flex: 1, - wordBreak: "break-word", - textDecoration: todo.status === "cancelled" ? "line-through" : undefined, - }} - > - {todo.content} - </Typography> - <Chip - size="small" - label={visual.label} - sx={{ - height: 22, - borderRadius: "11px", - fontSize: "0.68rem", - fontWeight: 800, - color: visual.color, - bgcolor: alpha(visual.color, 0.08), - "& .MuiChip-label": { px: 0.85 }, - }} - /> - </Stack> - ); - })} - </Stack> - ) : null} + <Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}> + {pinnedTodos.map((todo, index) => renderTodoRow(todo, index))} + {canCollapse ? ( + <Collapse in={expanded} timeout={220} unmountOnExit={false}> + <Stack spacing={0}> + {collapsibleTodos.map((todo, index) => + renderTodoRow(todo, index + pinnedTodos.length), + )} + </Stack> + </Collapse> + ) : null} + {hiddenCount > 0 ? ( + <Typography + variant="caption" + color="text.secondary" + sx={{ + pt: 0.8, + borderTop: `1px solid ${alpha("#00838f", 0.08)}`, + }} + > + 还有 {hiddenCount} 项,展开查看全部 + </Typography> + ) : null} + </Stack> </Box> ); }; @@ -1568,8 +1665,8 @@ export const AgentTurn = React.memo( /> ) : null} - {message.todos?.length ? ( - <TodoPlanCard todoUpdate={message.todos[message.todos.length - 1]} /> + {message.todos ? ( + <TodoPlanCard todoUpdate={message.todos} /> ) : null} <Box diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 8271380..c74c2b5 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -61,7 +61,7 @@ export type Message = { artifacts?: AgentArtifact[]; permissions?: AgentPermissionRequest[]; questions?: AgentQuestionRequest[]; - todos?: AgentTodoUpdate[]; + todos?: AgentTodoUpdate; }; export type Props = { diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 7948242..edc75cf 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -259,6 +259,94 @@ describe("useAgentChatSession", () => { } }); + it("shows shared todo state only on the latest assistant message in a session", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "in_progress", + }, + ], + createdAt: 1000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "completed", + }, + { + id: "todo-2", + content: "更新任务状态", + status: "in_progress", + }, + ], + createdAt: 2000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("创建任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("更新任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + const assistantMessages = result.current.messages.filter( + (message) => message.role === "assistant", + ); + + expect(assistantMessages).toHaveLength(2); + expect(assistantMessages[0].todos).toBeUndefined(); + expect(assistantMessages[1].todos).toEqual( + expect.objectContaining({ + sessionId: "session-1", + createdAt: 2000, + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "completed", + }), + expect.objectContaining({ + id: "todo-2", + status: "in_progress", + }), + ], + }), + ); + }); + it("hydrates a backend streaming session and resumes its stream", async () => { listChatSessions.mockResolvedValue([ { @@ -849,22 +937,20 @@ describe("useAgentChatSession", () => { endedAt: expect.any(Number), }), ], - todos: [ - expect.objectContaining({ - todos: [ - expect.objectContaining({ - id: "todo-1", - status: "cancelled", - updatedAt: expect.any(Number), - }), - expect.objectContaining({ - id: "todo-2", - status: "cancelled", - updatedAt: expect.any(Number), - }), - ], - }), - ], + todos: expect.objectContaining({ + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "cancelled", + updatedAt: expect.any(Number), + }), + expect.objectContaining({ + id: "todo-2", + status: "cancelled", + updatedAt: expect.any(Number), + }), + ], + }), permissions: [ expect.objectContaining({ requestId: "perm-abort", diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index b49bcef..bb1ace1 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -139,19 +139,21 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) => }; }); -const cancelRunningTodos = (todos: AgentTodoUpdate[] | undefined) => - todos?.map((todoUpdate) => ({ - ...todoUpdate, - todos: todoUpdate.todos.map((todo) => - todo.status === "pending" || todo.status === "in_progress" - ? { - ...todo, - status: "cancelled" as const, - updatedAt: Date.now(), - } - : todo, - ), - })); +const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => + todoUpdate + ? { + ...todoUpdate, + todos: todoUpdate.todos.map((todo) => + todo.status === "pending" || todo.status === "in_progress" + ? { + ...todo, + status: "cancelled" as const, + updatedAt: Date.now(), + } + : todo, + ), + } + : undefined; const upsertPermission = ( permissions: AgentPermissionRequest[] | undefined, @@ -354,17 +356,64 @@ const applyQuestionResponse = ( : question, ); -const upsertTodoUpdate = ( - todos: AgentTodoUpdate[] | undefined, +const createTodoUpdateFromEvent = ( event: StreamEvent & { type: "todo_update" }, -) => [ - { - sessionId: event.sessionId, - messageId: event.messageId, - todos: event.todos, - createdAt: event.createdAt, - }, -]; +): AgentTodoUpdate => ({ + sessionId: event.sessionId, + messageId: event.messageId, + todos: event.todos, + createdAt: event.createdAt, +}); + +const normalizeSessionTodos = ( + messages: Message[], + nextTodoUpdate?: AgentTodoUpdate, + targetAssistantMessageId?: string, +) => { + let latestTodoUpdate = nextTodoUpdate; + if (!latestTodoUpdate) { + for (const message of messages) { + if (message.todos) { + latestTodoUpdate = message.todos; + } + } + } + + if (!latestTodoUpdate) { + return messages; + } + + const targetMessageId = + targetAssistantMessageId ?? + [...messages].reverse().find((message) => message.role === "assistant")?.id; + if (!targetMessageId) { + return messages; + } + + let changed = false; + const nextMessages = messages.map((message) => { + if (message.id === targetMessageId) { + if (message.todos === latestTodoUpdate) { + return message; + } + changed = true; + return { + ...message, + todos: latestTodoUpdate, + }; + } + if (!message.todos) { + return message; + } + changed = true; + return { + ...message, + todos: undefined, + }; + }); + + return changed ? nextMessages : messages; +}; const rejectOpenPermissionsAfterAbort = ( permissions: AgentPermissionRequest[] | undefined, @@ -425,7 +474,7 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => { Boolean(rejectedPermissions?.length) || Boolean(rejectedQuestions?.length) || Boolean(completedProgress?.length) || - Boolean(cancelledTodos?.length); + Boolean(cancelledTodos); if (!hasVisibleOutput) { return message; @@ -544,7 +593,9 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - setMessages(dedupeQuestionsAcrossMessages(loadedState.messages)); + setMessages( + normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)), + ); setSessionTitle(loadedState.title); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setSessionId(loadedState.sessionId); @@ -654,8 +705,8 @@ export const useAgentChatSession = ({ } if (event.type === "state") { - const nextMessages = dedupeQuestionsAcrossMessages( - cloneMessages(event.messages as Message[]), + const nextMessages = normalizeSessionTodos( + dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])), ); messagesRef.current = nextMessages; setMessages(nextMessages); @@ -774,13 +825,10 @@ export const useAgentChatSession = ({ ); } else if (event.type === "todo_update") { setMessages((prev) => - prev.map((message) => - message.id === assistantMessageId - ? { - ...message, - todos: upsertTodoUpdate(message.todos, event), - } - : message, + normalizeSessionTodos( + prev, + createTodoUpdateFromEvent(event), + assistantMessageId, ), ); } else if (event.type === "done") { @@ -914,7 +962,7 @@ export const useAgentChatSession = ({ message.content.trim().length === 0 && !(message.artifacts?.length) && !(message.progress?.length) && - !(message.todos?.length) + !message.todos ), ), ); -- 2.54.0 From 36cdb1df8d7226364ea193232ee01a0fddb3665f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 19:23:46 +0800 Subject: [PATCH 171/281] refactor(chat): split oversized chat modules --- src/components/chat/AgentMarkdownBlock.tsx | 27 + .../chat/AgentPermissionRequests.tsx | 617 +++++++ src/components/chat/AgentQuestionRequests.tsx | 564 +++++++ src/components/chat/AgentTodoPlanCard.tsx | 308 ++++ src/components/chat/AgentTurn.tsx | 1458 +---------------- .../chat/hooks/agentChatSessionState.ts | 457 ++++++ .../useAgentChatSession.actions.test.tsx | 479 ++++++ .../useAgentChatSession.lifecycle.test.tsx | 791 +++++++++ .../chat/hooks/useAgentChatSession.test.tsx | 1196 +------------- .../chat/hooks/useAgentChatSession.ts | 510 +----- .../chat/hooks/useAgentChatSession.types.ts | 25 + 11 files changed, 3282 insertions(+), 3150 deletions(-) create mode 100644 src/components/chat/AgentMarkdownBlock.tsx create mode 100644 src/components/chat/AgentPermissionRequests.tsx create mode 100644 src/components/chat/AgentQuestionRequests.tsx create mode 100644 src/components/chat/AgentTodoPlanCard.tsx create mode 100644 src/components/chat/hooks/agentChatSessionState.ts create mode 100644 src/components/chat/hooks/useAgentChatSession.actions.test.tsx create mode 100644 src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx create mode 100644 src/components/chat/hooks/useAgentChatSession.types.ts diff --git a/src/components/chat/AgentMarkdownBlock.tsx b/src/components/chat/AgentMarkdownBlock.tsx new file mode 100644 index 0000000..b95c4fe --- /dev/null +++ b/src/components/chat/AgentMarkdownBlock.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import markdownStyles from "./GlobalChatboxMarkdown.module.css"; + +export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); + +export const MarkdownBlock = ({ children }: { children: string }) => { + const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => { + const selectedText = window.getSelection()?.toString(); + if (!selectedText) return; + + event.preventDefault(); + event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); + }, []); + + return ( + <div className={markdownStyles.markdown} onCopy={handleCopy}> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> + </div> + ); +}; + + diff --git a/src/components/chat/AgentPermissionRequests.tsx b/src/components/chat/AgentPermissionRequests.tsx new file mode 100644 index 0000000..cf67a8e --- /dev/null +++ b/src/components/chat/AgentPermissionRequests.tsx @@ -0,0 +1,617 @@ +"use client"; + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + Box, + Button, + Chip, + CircularProgress, + Collapse, + IconButton, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +import TerminalRounded from "@mui/icons-material/TerminalRounded"; +import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import BlockRounded from "@mui/icons-material/BlockRounded"; +import PushPinRounded from "@mui/icons-material/PushPinRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; +import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; + +import type { PermissionReply } from "@/lib/chatStream"; +import type { Message } from "./GlobalChatbox.types"; + +const formatMetadataValue = (value: unknown) => { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return "[unserializable]"; + } +}; + +const truncateText = (value: string, maxLength: number) => + value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; + +const formatMetadata = (metadata: Record<string, unknown>) => { + const entries = Object.entries(metadata) + .filter(([key]) => !["command", "path", "file", "directory"].includes(key)) + .slice(0, 3); + if (!entries.length) { + return ""; + } + return entries + .map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`) + .join(";"); +}; + +const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => { + if (permission.permission === "external_directory") return "访问工作区外目录"; + if (permission.permission === "bash") return "执行终端命令"; + if (permission.permission === "edit") return "修改文件内容"; + return permission.permission || "工具权限请求"; +}; + +const getPermissionPrimaryValue = ( + permission: NonNullable<Message["permissions"]>[number], +) => { + const command = permission.metadata.command; + if (typeof command === "string" && command.trim()) { + return command.trim(); + } + for (const key of ["path", "file", "directory"]) { + const value = permission.metadata[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return permission.patterns[0] ?? permission.permission; +}; + +const PermissionIcon = ({ + permission, +}: { + permission: NonNullable<Message["permissions"]>[number]; +}) => { + if (permission.permission === "bash") { + return <TerminalRounded sx={{ fontSize: 22 }} />; + } + if (permission.permission === "external_directory") { + return <FolderOpenRounded sx={{ fontSize: 22 }} />; + } + return <VerifiedUserRounded sx={{ fontSize: 22 }} />; +}; + +const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => { + if (status === "approved_always") return "已始终允许"; + if (status === "approved_once") return "已允许一次"; + if (status === "rejected") return "已拒绝"; + if (status === "error") return "提交失败"; + if (status === "submitting") return "提交中"; + return "等待确认"; +}; + +const pendingPermissionColor = "#f9a825"; +const approvedOncePermissionColor = "#00838f"; + +const getPermissionStatusColor = ( + status: NonNullable<Message["permissions"]>[number]["status"], + theme: Theme, +) => { + if (status === "approved_once") return approvedOncePermissionColor; + if (status === "approved_always") return theme.palette.success.main; + if (status === "rejected" || status === "error") return theme.palette.error.main; + return pendingPermissionColor; +}; + +const getPermissionStatusTextColor = ( + status: NonNullable<Message["permissions"]>[number]["status"], + theme: Theme, +) => { + if (status === "approved_once") return "#006c78"; + if (status === "approved_always") return theme.palette.success.dark; + if (status === "rejected" || status === "error") return theme.palette.error.main; + return "#8a5a00"; +}; + +const PermissionRequestCard = ({ + permission, + onReply, +}: { + permission: NonNullable<Message["permissions"]>[number]; + onReply: (requestId: string, reply: PermissionReply) => void; +}) => { + const theme = useTheme(); + const isPending = permission.status === "pending" || permission.status === "error"; + const isSubmitting = permission.status === "submitting"; + const primaryValue = getPermissionPrimaryValue(permission); + const metadataText = formatMetadata(permission.metadata); + const accentColor = getPermissionStatusColor(permission.status, theme); + const statusTextColor = getPermissionStatusTextColor(permission.status, theme); + const statusLabel = getPermissionStatusLabel(permission.status); + + return ( + <Box + sx={{ + borderRadius: 3, + overflow: "hidden", + border: `1px solid ${alpha("#fff", 0.72)}`, + bgcolor: alpha("#fff", 0.5), + boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, + backdropFilter: "blur(20px)", + position: "relative", + "&::before": { + content: '""', + position: "absolute", + inset: "10px auto 10px 0", + width: 3, + borderRadius: "0 999px 999px 0", + bgcolor: accentColor, + }, + }} + > + <Stack + direction="row" + spacing={1} + alignItems="center" + sx={{ + px: 1.5, + py: 1.25, + pl: 1.75, + borderBottom: `1px solid ${alpha("#000", 0.05)}`, + }} + > + <Box + sx={{ + width: 32, + height: 32, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: accentColor, + bgcolor: alpha(accentColor, 0.1), + border: `1px solid ${alpha(accentColor, 0.16)}`, + }} + > + <PermissionIcon permission={permission} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + {getPermissionTitle(permission)} + </Typography> + </Box> + <Chip + size="small" + label={statusLabel} + sx={{ + height: 24, + fontSize: "0.7rem", + fontWeight: 800, + borderRadius: "12px", + bgcolor: alpha(accentColor, 0.12), + color: statusTextColor, + "& .MuiChip-label": { px: 1 }, + }} + /> + </Stack> + + <Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}> + <Box + sx={{ + px: 1.25, + py: 1, + borderRadius: 2.5, + bgcolor: alpha("#000", 0.025), + border: `1px solid ${alpha("#000", 0.045)}`, + }} + > + <Typography variant="caption" color="text.secondary" fontWeight={800}> + 请求目标 + </Typography> + <Typography + variant="body2" + color="text.primary" + fontFamily={permission.permission === "bash" ? "monospace" : undefined} + sx={{ + mt: 0.25, + lineHeight: 1.55, + wordBreak: "break-word", + whiteSpace: "pre-wrap", + }} + > + {primaryValue} + </Typography> + </Box> + + {metadataText ? ( + <Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}> + {metadataText} + </Typography> + ) : null} + </Stack> + + {permission.error ? ( + <Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}> + <Typography + variant="caption" + color="error.main" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha(theme.palette.error.main, 0.06), + wordBreak: "break-word", + }} + > + {permission.error} + </Typography> + </Box> + ) : null} + + {isPending || isSubmitting ? ( + <Stack + direction="row" + spacing={1} + flexWrap="wrap" + useFlexGap + sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }} + > + <Button + size="small" + variant="contained" + disableElevation + disabled={isSubmitting} + onClick={() => onReply(permission.requestId, "once")} + startIcon={ + isSubmitting ? ( + <CircularProgress size={14} color="inherit" /> + ) : ( + <CheckCircleRounded fontSize="small" /> + ) + } + sx={{ + minWidth: 94, + height: 34, + borderRadius: "17px", + bgcolor: "#00838f", + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`, + "&:hover": { + bgcolor: "#006c78", + boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`, + }, + }} + > + 允许一次 + </Button> + <Button + size="small" + variant="outlined" + disabled={isSubmitting} + onClick={() => onReply(permission.requestId, "always")} + startIcon={<PushPinRounded fontSize="small" />} + sx={{ + height: 34, + borderRadius: "17px", + px: 1.5, + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + color: "#00838f", + borderColor: alpha("#00838f", 0.24), + bgcolor: alpha("#fff", 0.45), + "&:hover": { + borderColor: alpha("#00838f", 0.36), + bgcolor: alpha("#00838f", 0.08), + }, + }} + > + 始终允许 + </Button> + <Button + size="small" + color="error" + variant="outlined" + disabled={isSubmitting} + onClick={() => onReply(permission.requestId, "reject")} + startIcon={<BlockRounded fontSize="small" />} + sx={{ + height: 34, + borderRadius: "17px", + px: 1.5, + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + borderColor: alpha(theme.palette.error.main, 0.22), + bgcolor: alpha("#fff", 0.45), + "&:hover": { + borderColor: alpha(theme.palette.error.main, 0.34), + bgcolor: alpha(theme.palette.error.main, 0.07), + }, + }} + > + 拒绝 + </Button> + </Stack> + ) : null} + </Box> + ); +}; + +export const PermissionRequestGroup = ({ + permissions, + isRunning, + onReply, +}: { + permissions: NonNullable<Message["permissions"]>; + isRunning: boolean; + onReply: (requestId: string, reply: PermissionReply) => void; +}) => { + const theme = useTheme(); + const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; + const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length; + const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length; + const pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount; + const hasPendingPermissions = pendingCount > 0; + const [expanded, setExpanded] = React.useState(false); + const latestPermissions = permissions.slice(-3); + const pendingPermissions = permissions.filter( + (permission) => + permission.status === "pending" || + permission.status === "submitting" || + permission.status === "error", + ); + const summaryItems = [ + { label: "共", value: permissions.length, color: theme.palette.text.secondary }, + { label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, + { label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, + { label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) }, + ]; + const chipColor = pendingCount > 0 ? getPermissionStatusColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusColor("rejected", theme) : getPermissionStatusColor("approved_always", theme); + const chipTextColor = pendingCount > 0 ? getPermissionStatusTextColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusTextColor("rejected", theme) : getPermissionStatusTextColor("approved_always", theme); + + return ( + <Box + sx={{ + borderRadius: 3, + overflow: "hidden", + border: `1px solid ${alpha("#fff", 0.72)}`, + bgcolor: alpha("#fff", 0.46), + boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`, + backdropFilter: "blur(20px)", + }} + > + <Stack + direction="row" + alignItems="center" + spacing={1} + role="button" + tabIndex={0} + onClick={() => setExpanded((value) => !value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded((value) => !value); + } + }} + sx={{ + px: 1.5, + py: 1.15, + cursor: "pointer", + transition: "background-color 0.2s ease", + "&:hover": { bgcolor: alpha("#000", 0.025) }, + }} + > + <Box + sx={{ + width: 30, + height: 30, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: chipColor, + bgcolor: alpha(chipColor, 0.1), + border: `1px solid ${alpha(chipColor, 0.15)}`, + }} + > + <VerifiedUserRounded sx={{ fontSize: 18 }} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + 权限请求 + </Typography> + <Stack + direction="row" + flexWrap="wrap" + gap={0.6} + sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }} + > + {summaryItems.map((item) => ( + <Box + key={item.label} + component="span" + sx={{ + display: "inline-flex", + alignItems: "center", + gap: 0.45, + height: 22, + px: 0.8, + borderRadius: "11px", + bgcolor: alpha(item.color, 0.08), + border: `1px solid ${alpha(item.color, 0.12)}`, + color: "textColor" in item ? item.textColor : item.color, + fontSize: "0.7rem", + fontWeight: 800, + lineHeight: 1, + whiteSpace: "nowrap", + }} + > + <Box + component="span" + sx={{ + color: "textColor" in item ? item.textColor : item.color, + fontWeight: 700, + }} + > + {item.label} + </Box> + <Box component="span">{item.value} 项</Box> + </Box> + ))} + </Stack> + </Box> + {isRunning && pendingCount > 0 ? ( + <Chip + size="small" + label={`待确认 ${pendingCount} 项`} + sx={{ + height: 24, + borderRadius: "12px", + fontSize: "0.7rem", + fontWeight: 800, + color: chipTextColor, + bgcolor: alpha(chipColor, 0.1), + "& .MuiChip-label": { px: 1 }, + }} + /> + ) : null} + <IconButton + size="small" + aria-label={expanded ? "收起权限请求" : "展开权限请求"} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.035), + "&:hover": { bgcolor: alpha("#000", 0.07) }, + }} + > + {expanded ? ( + <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> + ) : ( + <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> + )} + </IconButton> + </Stack> + + {!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? ( + <Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}> + {latestPermissions.map((permission, index) => { + const primaryValue = getPermissionPrimaryValue(permission); + const isLast = index === latestPermissions.length - 1; + const itemColor = getPermissionStatusColor(permission.status, theme); + const itemTextColor = getPermissionStatusTextColor(permission.status, theme); + + return ( + <Stack + key={permission.requestId} + direction="row" + spacing={1} + alignItems="center" + sx={{ + py: 0.8, + borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none", + borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`, + }} + > + <Box + sx={{ + width: 24, + height: 24, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: itemColor, + bgcolor: alpha(itemColor, 0.08), + }} + > + <PermissionIcon permission={permission} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}> + {getPermissionTitle(permission)} + </Typography> + <Typography + variant="caption" + color="text.secondary" + noWrap + sx={{ + display: "block", + fontFamily: permission.permission === "bash" ? "monospace" : undefined, + }} + > + {truncateText(primaryValue, 72)} + </Typography> + </Box> + <Chip + size="small" + label={getPermissionStatusLabel(permission.status)} + sx={{ + height: 22, + borderRadius: "11px", + fontSize: "0.68rem", + fontWeight: 800, + color: itemTextColor, + bgcolor: alpha(itemColor, 0.08), + "& .MuiChip-label": { px: 0.85 }, + }} + /> + </Stack> + ); + })} + </Stack> + ) : null} + + <AnimatePresence initial={false}> + {!expanded && isRunning && hasPendingPermissions ? ( + <motion.div + key="pending-permissions" + initial={{ opacity: 0, y: -10, height: 0 }} + animate={{ opacity: 1, y: 0, height: "auto" }} + exit={{ opacity: 0, y: -8, height: 0 }} + transition={{ duration: 0.2, ease: "easeOut" }} + style={{ overflow: "hidden" }} + > + <Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}> + {pendingPermissions.map((permission) => ( + <PermissionRequestCard + key={permission.requestId} + permission={permission} + onReply={onReply} + /> + ))} + </Stack> + </motion.div> + ) : null} + </AnimatePresence> + + <Collapse in={expanded} timeout="auto" unmountOnExit> + <Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}> + {permissions.map((permission) => ( + <PermissionRequestCard + key={permission.requestId} + permission={permission} + onReply={onReply} + /> + ))} + </Stack> + </Collapse> + </Box> + ); +}; + + diff --git a/src/components/chat/AgentQuestionRequests.tsx b/src/components/chat/AgentQuestionRequests.tsx new file mode 100644 index 0000000..0698a56 --- /dev/null +++ b/src/components/chat/AgentQuestionRequests.tsx @@ -0,0 +1,564 @@ +"use client"; + +import React from "react"; +import { + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Collapse, + FormControlLabel, + Stack, + TextField, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; +import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded"; +import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded"; + +import type { Message } from "./GlobalChatbox.types"; + +const getQuestionStatusLabel = ( + status: NonNullable<Message["questions"]>[number]["status"], +) => { + if (status === "answered") return "已回答"; + if (status === "rejected") return "已跳过"; + if (status === "error") return "提交失败"; + if (status === "submitting") return "提交中"; + return "等待回答"; +}; + +const getQuestionStatusColor = ( + status: NonNullable<Message["questions"]>[number]["status"], + theme: Theme, +) => { + if (status === "answered") return theme.palette.success.main; + if (status === "rejected") return theme.palette.text.secondary; + if (status === "error") return theme.palette.error.main; + return "#0288d1"; +}; + +const QuestionRequestCard = ({ + questionRequest, + onReply, + onReject, +}: { + questionRequest: NonNullable<Message["questions"]>[number]; + onReply: (requestId: string, answers: string[][]) => void; + onReject: (requestId: string) => void; +}) => { + const theme = useTheme(); + const isEditable = + questionRequest.status === "pending" || questionRequest.status === "error"; + const isSubmitting = questionRequest.status === "submitting"; + const statusColor = getQuestionStatusColor(questionRequest.status, theme); + const [selected, setSelected] = React.useState<Record<number, string[]>>({}); + const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({}); + const [custom, setCustom] = React.useState<Record<number, string>>({}); + + const answers = React.useMemo( + () => + questionRequest.questions.map((question, index) => { + const selectedAnswers = selected[index] ?? []; + const isCustomSelected = + customSelected[index] === true || + (question.custom !== false && question.options.length === 0); + const customAnswer = custom[index]?.trim(); + return isCustomSelected && customAnswer + ? [...selectedAnswers, customAnswer] + : selectedAnswers; + }), + [custom, customSelected, questionRequest.questions, selected], + ); + + const canSubmit = + isEditable && + questionRequest.questions.length > 0 && + questionRequest.questions.every((_, index) => { + const answer = answers[index] ?? []; + return answer.some((item) => item.trim().length > 0); + }); + + const answerSummary = (questionRequest.answers ?? []) + .map((answer) => answer.join("、")) + .filter(Boolean) + .join(";"); + + return ( + <Box + sx={{ + borderRadius: 3, + overflow: "hidden", + border: `1px solid ${alpha("#fff", 0.72)}`, + bgcolor: alpha("#fff", 0.52), + boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, + backdropFilter: "blur(20px)", + position: "relative", + "&::before": { + content: '""', + position: "absolute", + inset: "10px auto 10px 0", + width: 3, + borderRadius: "0 999px 999px 0", + bgcolor: statusColor, + }, + }} + > + <Stack + direction="row" + spacing={1} + alignItems="center" + sx={{ + px: 1.5, + py: 1.25, + pl: 1.75, + borderBottom: `1px solid ${alpha("#000", 0.05)}`, + }} + > + <Box + sx={{ + width: 32, + height: 32, + borderRadius: "50%", + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: statusColor, + bgcolor: alpha(statusColor, 0.1), + border: `1px solid ${alpha(statusColor, 0.16)}`, + }} + > + <HelpOutlineRounded sx={{ fontSize: 21 }} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + 需要补充信息 + </Typography> + </Box> + <Chip + size="small" + label={getQuestionStatusLabel(questionRequest.status)} + sx={{ + height: 24, + fontSize: "0.7rem", + fontWeight: 800, + borderRadius: "12px", + bgcolor: alpha(statusColor, 0.12), + color: statusColor, + "& .MuiChip-label": { px: 1 }, + }} + /> + </Stack> + + <Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}> + {questionRequest.questions.map((question, index) => { + const selectedAnswers = selected[index] ?? []; + const isCustomEnabled = question.custom !== false; + const isCustomSelected = + customSelected[index] === true || + (isCustomEnabled && question.options.length === 0); + const setQuestionAnswers = (nextAnswers: string[]) => { + setSelected((current) => ({ + ...current, + [index]: nextAnswers, + })); + }; + const setQuestionCustomSelected = (checked: boolean) => { + setCustomSelected((current) => ({ + ...current, + [index]: checked, + })); + }; + + return ( + <Box + key={`${question.header}-${index}`} + sx={{ + px: 1.25, + py: 1, + borderRadius: 2.5, + bgcolor: alpha("#000", 0.025), + border: `1px solid ${alpha("#000", 0.045)}`, + }} + > + <Typography variant="caption" color="text.secondary" fontWeight={800}> + {question.header || `问题 ${index + 1}`} + </Typography> + <Typography + variant="body2" + color="text.primary" + sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }} + > + {question.question} + </Typography> + + {question.options.length ? ( + <Stack spacing={0.75} sx={{ mt: 1 }}> + {question.options.map((option) => { + const checked = selectedAnswers.includes(option.label); + if (question.multiple) { + return ( + <FormControlLabel + key={option.label} + disabled={!isEditable || isSubmitting} + control={ + <Checkbox + size="small" + checked={checked} + onChange={(event) => { + if (event.target.checked) { + setQuestionAnswers([...selectedAnswers, option.label]); + } else { + setQuestionAnswers( + selectedAnswers.filter((item) => item !== option.label), + ); + } + }} + /> + } + label={ + <Box> + <Typography variant="body2" fontWeight={750}> + {option.label} + </Typography> + {option.description ? ( + <Typography variant="caption" color="text.secondary"> + {option.description} + </Typography> + ) : null} + </Box> + } + sx={{ alignItems: "flex-start", m: 0 }} + /> + ); + } + return ( + <Button + key={option.label} + size="small" + variant={checked ? "contained" : "outlined"} + disabled={!isEditable || isSubmitting} + onClick={() => { + setQuestionAnswers([option.label]); + setQuestionCustomSelected(false); + }} + startIcon={ + checked ? ( + <CheckCircleRounded fontSize="small" /> + ) : ( + <RadioButtonUncheckedRounded fontSize="small" /> + ) + } + sx={{ + justifyContent: "flex-start", + minHeight: 38, + borderRadius: 2, + textTransform: "none", + fontWeight: 800, + bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45), + borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22), + "&:hover": { + bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08), + }, + }} + > + <Box sx={{ textAlign: "left", minWidth: 0 }}> + <Typography variant="body2" fontWeight={800}> + {option.label} + </Typography> + {option.description ? ( + <Typography + variant="caption" + sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }} + > + {option.description} + </Typography> + ) : null} + </Box> + </Button> + ); + })} + {isCustomEnabled ? ( + question.multiple ? ( + <FormControlLabel + disabled={!isEditable || isSubmitting} + control={ + <Checkbox + size="small" + checked={isCustomSelected} + onChange={(event) => + setQuestionCustomSelected(event.target.checked) + } + sx={{ + p: 0.5, + color: alpha("#0288d1", 0.55), + "&.Mui-checked": { color: "#0288d1" }, + }} + /> + } + label={ + <Stack direction="row" spacing={0.75} alignItems="center"> + <EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} /> + <Typography variant="body2" fontWeight={800}> + 自定义回答 + </Typography> + </Stack> + } + sx={{ + alignItems: "center", + minHeight: 38, + m: 0, + px: 0.75, + py: 0.25, + borderRadius: 2, + border: `1px solid ${ + isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18) + }`, + bgcolor: isCustomSelected + ? alpha("#0288d1", 0.1) + : alpha("#fff", 0.45), + transition: "background-color 0.18s ease, border-color 0.18s ease", + "&:hover": { + bgcolor: isCustomSelected + ? alpha("#0288d1", 0.13) + : alpha("#0288d1", 0.07), + }, + "& .MuiFormControlLabel-label": { + color: isCustomSelected ? "#0277bd" : "text.primary", + }, + }} + /> + ) : ( + <Button + size="small" + variant={isCustomSelected ? "contained" : "outlined"} + disabled={!isEditable || isSubmitting} + onClick={() => { + setQuestionAnswers([]); + setQuestionCustomSelected(true); + }} + startIcon={ + isCustomSelected ? ( + <CheckCircleRounded fontSize="small" /> + ) : ( + <EditNoteRounded fontSize="small" /> + ) + } + sx={{ + justifyContent: "flex-start", + minHeight: 38, + borderRadius: 2, + textTransform: "none", + fontWeight: 800, + bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45), + borderColor: isCustomSelected + ? "#0288d1" + : alpha("#0288d1", 0.22), + "&:hover": { + bgcolor: isCustomSelected + ? "#0277bd" + : alpha("#0288d1", 0.08), + }, + }} + > + <Box sx={{ textAlign: "left", minWidth: 0 }}> + <Typography variant="body2" fontWeight={800}> + 自定义回答 + </Typography> + </Box> + </Button> + ) + ) : null} + </Stack> + ) : null} + + <Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit> + <Box + sx={{ + mt: 0.85, + px: 1.15, + py: 0.85, + borderRadius: 2.5, + bgcolor: alpha("#fff", 0.62), + border: `1px solid ${alpha("#fff", 0.82)}`, + boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`, + backdropFilter: "blur(18px)", + }} + > + <TextField + multiline + minRows={2} + maxRows={5} + fullWidth + variant="standard" + disabled={!isEditable || isSubmitting} + value={custom[index] ?? ""} + onChange={(event) => + setCustom((current) => ({ + ...current, + [index]: event.target.value, + })) + } + placeholder="输入自定义回答" + InputProps={{ + disableUnderline: true, + sx: { + alignItems: "flex-start", + fontSize: "0.88rem", + lineHeight: 1.55, + fontWeight: 500, + color: "text.primary", + "& textarea::placeholder": { + color: alpha(theme.palette.text.primary, 0.38), + opacity: 1, + }, + }, + }} + /> + </Box> + </Collapse> + </Box> + ); + })} + + {questionRequest.status === "answered" ? ( + <Typography + variant="caption" + color="success.main" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha(theme.palette.success.main, 0.07), + wordBreak: "break-word", + }} + > + 已回答{answerSummary ? `:${answerSummary}` : ""} + </Typography> + ) : null} + + {questionRequest.status === "rejected" ? ( + <Typography + variant="caption" + color="text.secondary" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha("#000", 0.035), + }} + > + 已跳过 + </Typography> + ) : null} + + {questionRequest.error ? ( + <Typography + variant="caption" + color="error.main" + sx={{ + display: "block", + px: 1.25, + py: 0.75, + borderRadius: 2, + bgcolor: alpha(theme.palette.error.main, 0.06), + wordBreak: "break-word", + }} + > + {questionRequest.error} + </Typography> + ) : null} + </Stack> + + {isEditable || isSubmitting ? ( + <Stack + direction="row" + spacing={1} + flexWrap="wrap" + useFlexGap + sx={{ px: 1.5, pb: 1.35, pl: 1.75 }} + > + <Button + size="small" + variant="outlined" + disabled={isSubmitting} + onClick={() => onReject(questionRequest.requestId)} + sx={{ + height: 34, + borderRadius: "17px", + px: 1.5, + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + color: "text.secondary", + borderColor: alpha(theme.palette.text.secondary, 0.22), + bgcolor: alpha("#fff", 0.45), + }} + > + 跳过 + </Button> + <Button + size="small" + variant="contained" + disableElevation + disabled={!canSubmit || isSubmitting} + onClick={() => onReply(questionRequest.requestId, answers)} + startIcon={ + isSubmitting ? ( + <CircularProgress size={14} color="inherit" /> + ) : ( + <CheckCircleRounded fontSize="small" /> + ) + } + sx={{ + minWidth: 104, + height: 34, + borderRadius: "17px", + bgcolor: "#0288d1", + fontWeight: 800, + fontSize: "0.78rem", + textTransform: "none", + boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`, + "&:hover": { + bgcolor: "#0277bd", + boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`, + }, + }} + > + 提交回答 + </Button> + </Stack> + ) : null} + </Box> + ); +}; + +export const QuestionRequestGroup = ({ + questions, + onReply, + onReject, +}: { + questions: NonNullable<Message["questions"]>; + onReply: (requestId: string, answers: string[][]) => void; + onReject: (requestId: string) => void; +}) => ( + <Stack spacing={1}> + {questions.map((question) => ( + <QuestionRequestCard + key={question.requestId} + questionRequest={question} + onReply={onReply} + onReject={onReject} + /> + ))} + </Stack> +); + + diff --git a/src/components/chat/AgentTodoPlanCard.tsx b/src/components/chat/AgentTodoPlanCard.tsx new file mode 100644 index 0000000..6461ff2 --- /dev/null +++ b/src/components/chat/AgentTodoPlanCard.tsx @@ -0,0 +1,308 @@ +"use client"; + +import React from "react"; +import { + Box, + Chip, + CircularProgress, + Collapse, + IconButton, + Stack, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import BlockRounded from "@mui/icons-material/BlockRounded"; +import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; +import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; +import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded"; + +import type { Message } from "./GlobalChatbox.types"; + +export const TodoPlanCard = ({ + todoUpdate, +}: { + todoUpdate: NonNullable<Message["todos"]>; +}) => { + const theme = useTheme(); + const total = todoUpdate.todos.length; + const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; + const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); + const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; + const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length; + const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + const isAborted = cancelled > 0 && completed + cancelled === total; + const canCollapse = total > 4; + const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted); + const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos; + const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : []; + const hiddenCount = expanded ? 0 : collapsibleTodos.length; + const latestUpdatedAt = Math.max( + todoUpdate.createdAt, + ...todoUpdate.todos + .map((todo) => todo.updatedAt ?? todo.createdAt ?? 0) + .filter((value) => value > 0), + ); + const updatedAtLabel = + latestUpdatedAt > 0 + ? new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(latestUpdatedAt)) + : undefined; + + const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => { + if (status === "completed") { + return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" }; + } + if (status === "in_progress") { + return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" }; + } + if (status === "cancelled") { + return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" }; + } + return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" }; + }; + + const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => { + if (priority === "high") return { label: "高优先级", color: "#8a5a00" }; + if (priority === "medium") return { label: "中优先级", color: "#9a6a16" }; + if (priority === "low") return { label: "低优先级", color: "#8d7960" }; + return undefined; + }; + + const statusSummary = isAborted + ? `${completed} 完成 / ${cancelled} 中止` + : [ + completed ? `${completed} 完成` : null, + running ? "1 进行中" : null, + pending ? `${pending} 待办` : null, + cancelled ? `${cancelled} 中止` : null, + ].filter(Boolean).join(" / ") || "等待任务"; + const renderTodoRow = ( + todo: NonNullable<Message["todos"]>["todos"][number], + index: number, + ) => { + const visual = getTodoVisual(todo.status); + const priority = getPriorityLabel(todo.priority); + return ( + <Stack + key={`${todo.id}-${index}`} + direction="row" + alignItems="flex-start" + spacing={1} + sx={{ + py: 0.8, + borderTop: `1px solid ${alpha("#00838f", 0.08)}`, + color: todo.status === "cancelled" ? "text.disabled" : "text.primary", + }} + > + <Box + sx={{ + width: 24, + height: 24, + borderRadius: 1.25, + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: visual.color, + bgcolor: alpha(visual.color, 0.08), + mt: 0.1, + }} + > + {visual.icon} + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Typography + variant="body2" + sx={{ + minWidth: 0, + wordBreak: "break-word", + lineHeight: 1.45, + textDecoration: todo.status === "cancelled" ? "line-through" : undefined, + }} + > + {todo.content} + </Typography> + </Box> + <Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}> + {priority ? ( + <Chip + size="small" + label={priority.label} + sx={{ + height: 22, + borderRadius: "11px", + fontSize: "0.66rem", + fontWeight: 800, + color: priority.color, + bgcolor: alpha(priority.color, 0.045), + border: `1px solid ${alpha(priority.color, 0.16)}`, + "& .MuiChip-label": { px: 0.75 }, + }} + /> + ) : null} + <Chip + size="small" + label={visual.label} + sx={{ + height: 22, + borderRadius: "11px", + fontSize: "0.66rem", + fontWeight: 800, + color: visual.color, + bgcolor: alpha(visual.color, 0.08), + "& .MuiChip-label": { px: 0.75 }, + }} + /> + </Stack> + </Stack> + ); + }; + + if (total === 0) { + return null; + } + + return ( + <Box + sx={{ + borderRadius: 2, + overflow: "hidden", + border: `1px solid ${alpha("#00838f", 0.16)}`, + bgcolor: alpha("#f8fbfc", 0.82), + }} + > + <Stack + spacing={1} + role="button" + tabIndex={0} + onClick={() => { + if (canCollapse) { + setExpanded((value) => !value); + } + }} + onKeyDown={(event) => { + if (canCollapse && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + setExpanded((value) => !value); + } + }} + sx={{ + px: 1.4, + py: 1.15, + cursor: canCollapse ? "pointer" : "default", + transition: "background-color 0.2s ease", + "&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined, + }} + > + <Stack direction="row" alignItems="center" spacing={1}> + <Box + sx={{ + width: 28, + height: 28, + borderRadius: 1.5, + display: "grid", + placeItems: "center", + flex: "0 0 auto", + color: "#00838f", + bgcolor: alpha("#00838f", 0.1), + border: `1px solid ${alpha("#00838f", 0.14)}`, + }} + > + <AssignmentTurnedInRounded sx={{ fontSize: 18 }} /> + </Box> + <Box sx={{ minWidth: 0, flex: 1 }}> + <Stack direction="row" alignItems="center" spacing={0.75}> + <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> + 会话任务 + </Typography> + <Chip + size="small" + label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"} + sx={{ + height: 20, + borderRadius: "10px", + fontSize: "0.66rem", + fontWeight: 800, + color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f", + bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08), + "& .MuiChip-label": { px: 0.75 }, + }} + /> + </Stack> + <Typography variant="caption" color="text.secondary"> + {statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""} + </Typography> + </Box> + {canCollapse ? ( + <IconButton + size="small" + aria-label={expanded ? "收起会话任务" : "展开会话任务"} + sx={{ + width: 28, + height: 28, + color: "text.secondary", + bgcolor: alpha("#000", 0.035), + "&:hover": { bgcolor: alpha("#000", 0.07) }, + }} + > + {expanded ? ( + <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> + ) : ( + <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> + )} + </IconButton> + ) : null} + </Stack> + <Box + sx={{ + height: 6, + borderRadius: 999, + overflow: "hidden", + bgcolor: alpha("#00838f", 0.1), + }} + > + <Box + sx={{ + width: `${progress}%`, + height: "100%", + borderRadius: 999, + bgcolor: isAborted ? theme.palette.text.disabled : "#00838f", + transition: "width 0.25s ease", + }} + /> + </Box> + </Stack> + + <Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}> + {pinnedTodos.map((todo, index) => renderTodoRow(todo, index))} + {canCollapse ? ( + <Collapse in={expanded} timeout={220} unmountOnExit={false}> + <Stack spacing={0}> + {collapsibleTodos.map((todo, index) => + renderTodoRow(todo, index + pinnedTodos.length), + )} + </Stack> + </Collapse> + ) : null} + {hiddenCount > 0 ? ( + <Typography + variant="caption" + color="text.secondary" + sx={{ + pt: 0.8, + borderTop: `1px solid ${alpha("#00838f", 0.08)}`, + }} + > + 还有 {hiddenCount} 项,展开查看全部 + </Typography> + ) : null} + </Stack> + </Box> + ); +}; + + diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 0597475..4acf1e9 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -2,62 +2,41 @@ import Image from "next/image"; import React, { useMemo } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { AnimatePresence, motion } from "framer-motion"; import { Avatar, Box, - Button, - Checkbox, - Chip, - CircularProgress, - Collapse, - FormControlLabel, IconButton, Paper, Stack, - TextField, Tooltip, Typography, alpha, useTheme, } from "@mui/material"; -import type { Theme } from "@mui/material/styles"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import RefreshRounded from "@mui/icons-material/RefreshRounded"; import { TbArrowsSplit2 } from "react-icons/tb"; +import type { PermissionReply } from "@/lib/chatStream"; import { parseAssistantMessageSections, parseContentWithToolCalls, type ContentSegment, } from "./chatMessageSections"; -import markdownStyles from "./GlobalChatboxMarkdown.module.css"; import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChatInlineChart } from "./ChatInlineChart"; import type { ChatChartSeries } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; -import { AgentArtifactPanel } from "./AgentArtifactPanel"; -import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; +import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; +import { PermissionRequestGroup } from "./AgentPermissionRequests"; +import { QuestionRequestGroup } from "./AgentQuestionRequests"; +import { TodoPlanCard } from "./AgentTodoPlanCard"; import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import StopRounded from "@mui/icons-material/StopRounded"; -import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; -import TerminalRounded from "@mui/icons-material/TerminalRounded"; -import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; -import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; -import BlockRounded from "@mui/icons-material/BlockRounded"; -import PushPinRounded from "@mui/icons-material/PushPinRounded"; -import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; -import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; -import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; -import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded"; -import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded"; -import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded"; -import type { PermissionReply } from "@/lib/chatStream"; type AgentTurnProps = { message: Message; @@ -74,1433 +53,6 @@ type AgentTurnProps = { onRejectQuestion: (requestId: string) => void; }; -const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); - -const MarkdownBlock = ({ children }: { children: string }) => { - const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => { - const selectedText = window.getSelection()?.toString(); - if (!selectedText) return; - - event.preventDefault(); - event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); - }, []); - - return ( - <div className={markdownStyles.markdown} onCopy={handleCopy}> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> - </div> - ); -}; - -const formatMetadataValue = (value: unknown) => { - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return "[unserializable]"; - } -}; - -const truncateText = (value: string, maxLength: number) => - value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; - -const formatMetadata = (metadata: Record<string, unknown>) => { - const entries = Object.entries(metadata) - .filter(([key]) => !["command", "path", "file", "directory"].includes(key)) - .slice(0, 3); - if (!entries.length) { - return ""; - } - return entries - .map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`) - .join(";"); -}; - -const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => { - if (permission.permission === "external_directory") return "访问工作区外目录"; - if (permission.permission === "bash") return "执行终端命令"; - if (permission.permission === "edit") return "修改文件内容"; - return permission.permission || "工具权限请求"; -}; - -const getPermissionPrimaryValue = ( - permission: NonNullable<Message["permissions"]>[number], -) => { - const command = permission.metadata.command; - if (typeof command === "string" && command.trim()) { - return command.trim(); - } - for (const key of ["path", "file", "directory"]) { - const value = permission.metadata[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - } - return permission.patterns[0] ?? permission.permission; -}; - -const PermissionIcon = ({ - permission, -}: { - permission: NonNullable<Message["permissions"]>[number]; -}) => { - if (permission.permission === "bash") { - return <TerminalRounded sx={{ fontSize: 22 }} />; - } - if (permission.permission === "external_directory") { - return <FolderOpenRounded sx={{ fontSize: 22 }} />; - } - return <VerifiedUserRounded sx={{ fontSize: 22 }} />; -}; - -const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => { - if (status === "approved_always") return "已始终允许"; - if (status === "approved_once") return "已允许一次"; - if (status === "rejected") return "已拒绝"; - if (status === "error") return "提交失败"; - if (status === "submitting") return "提交中"; - return "等待确认"; -}; - -const pendingPermissionColor = "#f9a825"; -const approvedOncePermissionColor = "#00838f"; - -const getPermissionStatusColor = ( - status: NonNullable<Message["permissions"]>[number]["status"], - theme: Theme, -) => { - if (status === "approved_once") return approvedOncePermissionColor; - if (status === "approved_always") return theme.palette.success.main; - if (status === "rejected" || status === "error") return theme.palette.error.main; - return pendingPermissionColor; -}; - -const getPermissionStatusTextColor = ( - status: NonNullable<Message["permissions"]>[number]["status"], - theme: Theme, -) => { - if (status === "approved_once") return "#006c78"; - if (status === "approved_always") return theme.palette.success.dark; - if (status === "rejected" || status === "error") return theme.palette.error.main; - return "#8a5a00"; -}; - -const PermissionRequestCard = ({ - permission, - onReply, -}: { - permission: NonNullable<Message["permissions"]>[number]; - onReply: (requestId: string, reply: PermissionReply) => void; -}) => { - const theme = useTheme(); - const isPending = permission.status === "pending" || permission.status === "error"; - const isSubmitting = permission.status === "submitting"; - const primaryValue = getPermissionPrimaryValue(permission); - const metadataText = formatMetadata(permission.metadata); - const accentColor = getPermissionStatusColor(permission.status, theme); - const statusTextColor = getPermissionStatusTextColor(permission.status, theme); - const statusLabel = getPermissionStatusLabel(permission.status); - - return ( - <Box - sx={{ - borderRadius: 3, - overflow: "hidden", - border: `1px solid ${alpha("#fff", 0.72)}`, - bgcolor: alpha("#fff", 0.5), - boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, - backdropFilter: "blur(20px)", - position: "relative", - "&::before": { - content: '""', - position: "absolute", - inset: "10px auto 10px 0", - width: 3, - borderRadius: "0 999px 999px 0", - bgcolor: accentColor, - }, - }} - > - <Stack - direction="row" - spacing={1} - alignItems="center" - sx={{ - px: 1.5, - py: 1.25, - pl: 1.75, - borderBottom: `1px solid ${alpha("#000", 0.05)}`, - }} - > - <Box - sx={{ - width: 32, - height: 32, - borderRadius: "50%", - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: accentColor, - bgcolor: alpha(accentColor, 0.1), - border: `1px solid ${alpha(accentColor, 0.16)}`, - }} - > - <PermissionIcon permission={permission} /> - </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> - {getPermissionTitle(permission)} - </Typography> - </Box> - <Chip - size="small" - label={statusLabel} - sx={{ - height: 24, - fontSize: "0.7rem", - fontWeight: 800, - borderRadius: "12px", - bgcolor: alpha(accentColor, 0.12), - color: statusTextColor, - "& .MuiChip-label": { px: 1 }, - }} - /> - </Stack> - - <Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}> - <Box - sx={{ - px: 1.25, - py: 1, - borderRadius: 2.5, - bgcolor: alpha("#000", 0.025), - border: `1px solid ${alpha("#000", 0.045)}`, - }} - > - <Typography variant="caption" color="text.secondary" fontWeight={800}> - 请求目标 - </Typography> - <Typography - variant="body2" - color="text.primary" - fontFamily={permission.permission === "bash" ? "monospace" : undefined} - sx={{ - mt: 0.25, - lineHeight: 1.55, - wordBreak: "break-word", - whiteSpace: "pre-wrap", - }} - > - {primaryValue} - </Typography> - </Box> - - {metadataText ? ( - <Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}> - {metadataText} - </Typography> - ) : null} - </Stack> - - {permission.error ? ( - <Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}> - <Typography - variant="caption" - color="error.main" - sx={{ - display: "block", - px: 1.25, - py: 0.75, - borderRadius: 2, - bgcolor: alpha(theme.palette.error.main, 0.06), - wordBreak: "break-word", - }} - > - {permission.error} - </Typography> - </Box> - ) : null} - - {isPending || isSubmitting ? ( - <Stack - direction="row" - spacing={1} - flexWrap="wrap" - useFlexGap - sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }} - > - <Button - size="small" - variant="contained" - disableElevation - disabled={isSubmitting} - onClick={() => onReply(permission.requestId, "once")} - startIcon={ - isSubmitting ? ( - <CircularProgress size={14} color="inherit" /> - ) : ( - <CheckCircleRounded fontSize="small" /> - ) - } - sx={{ - minWidth: 94, - height: 34, - borderRadius: "17px", - bgcolor: "#00838f", - fontWeight: 800, - fontSize: "0.78rem", - textTransform: "none", - boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`, - "&:hover": { - bgcolor: "#006c78", - boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`, - }, - }} - > - 允许一次 - </Button> - <Button - size="small" - variant="outlined" - disabled={isSubmitting} - onClick={() => onReply(permission.requestId, "always")} - startIcon={<PushPinRounded fontSize="small" />} - sx={{ - height: 34, - borderRadius: "17px", - px: 1.5, - fontWeight: 800, - fontSize: "0.78rem", - textTransform: "none", - color: "#00838f", - borderColor: alpha("#00838f", 0.24), - bgcolor: alpha("#fff", 0.45), - "&:hover": { - borderColor: alpha("#00838f", 0.36), - bgcolor: alpha("#00838f", 0.08), - }, - }} - > - 始终允许 - </Button> - <Button - size="small" - color="error" - variant="outlined" - disabled={isSubmitting} - onClick={() => onReply(permission.requestId, "reject")} - startIcon={<BlockRounded fontSize="small" />} - sx={{ - height: 34, - borderRadius: "17px", - px: 1.5, - fontWeight: 800, - fontSize: "0.78rem", - textTransform: "none", - borderColor: alpha(theme.palette.error.main, 0.22), - bgcolor: alpha("#fff", 0.45), - "&:hover": { - borderColor: alpha(theme.palette.error.main, 0.34), - bgcolor: alpha(theme.palette.error.main, 0.07), - }, - }} - > - 拒绝 - </Button> - </Stack> - ) : null} - </Box> - ); -}; - -const PermissionRequestGroup = ({ - permissions, - isRunning, - onReply, -}: { - permissions: NonNullable<Message["permissions"]>; - isRunning: boolean; - onReply: (requestId: string, reply: PermissionReply) => void; -}) => { - const theme = useTheme(); - const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; - const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length; - const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length; - const pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount; - const hasPendingPermissions = pendingCount > 0; - const [expanded, setExpanded] = React.useState(false); - const latestPermissions = permissions.slice(-3); - const pendingPermissions = permissions.filter( - (permission) => - permission.status === "pending" || - permission.status === "submitting" || - permission.status === "error", - ); - const summaryItems = [ - { label: "共", value: permissions.length, color: theme.palette.text.secondary }, - { label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, - { label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, - { label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) }, - ]; - const chipColor = pendingCount > 0 ? getPermissionStatusColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusColor("rejected", theme) : getPermissionStatusColor("approved_always", theme); - const chipTextColor = pendingCount > 0 ? getPermissionStatusTextColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusTextColor("rejected", theme) : getPermissionStatusTextColor("approved_always", theme); - - return ( - <Box - sx={{ - borderRadius: 3, - overflow: "hidden", - border: `1px solid ${alpha("#fff", 0.72)}`, - bgcolor: alpha("#fff", 0.46), - boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`, - backdropFilter: "blur(20px)", - }} - > - <Stack - direction="row" - alignItems="center" - spacing={1} - role="button" - tabIndex={0} - onClick={() => setExpanded((value) => !value)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setExpanded((value) => !value); - } - }} - sx={{ - px: 1.5, - py: 1.15, - cursor: "pointer", - transition: "background-color 0.2s ease", - "&:hover": { bgcolor: alpha("#000", 0.025) }, - }} - > - <Box - sx={{ - width: 30, - height: 30, - borderRadius: "50%", - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: chipColor, - bgcolor: alpha(chipColor, 0.1), - border: `1px solid ${alpha(chipColor, 0.15)}`, - }} - > - <VerifiedUserRounded sx={{ fontSize: 18 }} /> - </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> - 权限请求 - </Typography> - <Stack - direction="row" - flexWrap="wrap" - gap={0.6} - sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }} - > - {summaryItems.map((item) => ( - <Box - key={item.label} - component="span" - sx={{ - display: "inline-flex", - alignItems: "center", - gap: 0.45, - height: 22, - px: 0.8, - borderRadius: "11px", - bgcolor: alpha(item.color, 0.08), - border: `1px solid ${alpha(item.color, 0.12)}`, - color: "textColor" in item ? item.textColor : item.color, - fontSize: "0.7rem", - fontWeight: 800, - lineHeight: 1, - whiteSpace: "nowrap", - }} - > - <Box - component="span" - sx={{ - color: "textColor" in item ? item.textColor : item.color, - fontWeight: 700, - }} - > - {item.label} - </Box> - <Box component="span">{item.value} 项</Box> - </Box> - ))} - </Stack> - </Box> - {isRunning && pendingCount > 0 ? ( - <Chip - size="small" - label={`待确认 ${pendingCount} 项`} - sx={{ - height: 24, - borderRadius: "12px", - fontSize: "0.7rem", - fontWeight: 800, - color: chipTextColor, - bgcolor: alpha(chipColor, 0.1), - "& .MuiChip-label": { px: 1 }, - }} - /> - ) : null} - <IconButton - size="small" - aria-label={expanded ? "收起权限请求" : "展开权限请求"} - sx={{ - width: 28, - height: 28, - color: "text.secondary", - bgcolor: alpha("#000", 0.035), - "&:hover": { bgcolor: alpha("#000", 0.07) }, - }} - > - {expanded ? ( - <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> - ) : ( - <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> - )} - </IconButton> - </Stack> - - {!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? ( - <Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}> - {latestPermissions.map((permission, index) => { - const primaryValue = getPermissionPrimaryValue(permission); - const isLast = index === latestPermissions.length - 1; - const itemColor = getPermissionStatusColor(permission.status, theme); - const itemTextColor = getPermissionStatusTextColor(permission.status, theme); - - return ( - <Stack - key={permission.requestId} - direction="row" - spacing={1} - alignItems="center" - sx={{ - py: 0.8, - borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none", - borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`, - }} - > - <Box - sx={{ - width: 24, - height: 24, - borderRadius: "50%", - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: itemColor, - bgcolor: alpha(itemColor, 0.08), - }} - > - <PermissionIcon permission={permission} /> - </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}> - {getPermissionTitle(permission)} - </Typography> - <Typography - variant="caption" - color="text.secondary" - noWrap - sx={{ - display: "block", - fontFamily: permission.permission === "bash" ? "monospace" : undefined, - }} - > - {truncateText(primaryValue, 72)} - </Typography> - </Box> - <Chip - size="small" - label={getPermissionStatusLabel(permission.status)} - sx={{ - height: 22, - borderRadius: "11px", - fontSize: "0.68rem", - fontWeight: 800, - color: itemTextColor, - bgcolor: alpha(itemColor, 0.08), - "& .MuiChip-label": { px: 0.85 }, - }} - /> - </Stack> - ); - })} - </Stack> - ) : null} - - <AnimatePresence initial={false}> - {!expanded && isRunning && hasPendingPermissions ? ( - <motion.div - key="pending-permissions" - initial={{ opacity: 0, y: -10, height: 0 }} - animate={{ opacity: 1, y: 0, height: "auto" }} - exit={{ opacity: 0, y: -8, height: 0 }} - transition={{ duration: 0.2, ease: "easeOut" }} - style={{ overflow: "hidden" }} - > - <Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}> - {pendingPermissions.map((permission) => ( - <PermissionRequestCard - key={permission.requestId} - permission={permission} - onReply={onReply} - /> - ))} - </Stack> - </motion.div> - ) : null} - </AnimatePresence> - - <Collapse in={expanded} timeout="auto" unmountOnExit> - <Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}> - {permissions.map((permission) => ( - <PermissionRequestCard - key={permission.requestId} - permission={permission} - onReply={onReply} - /> - ))} - </Stack> - </Collapse> - </Box> - ); -}; - -const getQuestionStatusLabel = ( - status: NonNullable<Message["questions"]>[number]["status"], -) => { - if (status === "answered") return "已回答"; - if (status === "rejected") return "已跳过"; - if (status === "error") return "提交失败"; - if (status === "submitting") return "提交中"; - return "等待回答"; -}; - -const getQuestionStatusColor = ( - status: NonNullable<Message["questions"]>[number]["status"], - theme: Theme, -) => { - if (status === "answered") return theme.palette.success.main; - if (status === "rejected") return theme.palette.text.secondary; - if (status === "error") return theme.palette.error.main; - return "#0288d1"; -}; - -const QuestionRequestCard = ({ - questionRequest, - onReply, - onReject, -}: { - questionRequest: NonNullable<Message["questions"]>[number]; - onReply: (requestId: string, answers: string[][]) => void; - onReject: (requestId: string) => void; -}) => { - const theme = useTheme(); - const isEditable = - questionRequest.status === "pending" || questionRequest.status === "error"; - const isSubmitting = questionRequest.status === "submitting"; - const statusColor = getQuestionStatusColor(questionRequest.status, theme); - const [selected, setSelected] = React.useState<Record<number, string[]>>({}); - const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({}); - const [custom, setCustom] = React.useState<Record<number, string>>({}); - - const answers = React.useMemo( - () => - questionRequest.questions.map((question, index) => { - const selectedAnswers = selected[index] ?? []; - const isCustomSelected = - customSelected[index] === true || - (question.custom !== false && question.options.length === 0); - const customAnswer = custom[index]?.trim(); - return isCustomSelected && customAnswer - ? [...selectedAnswers, customAnswer] - : selectedAnswers; - }), - [custom, customSelected, questionRequest.questions, selected], - ); - - const canSubmit = - isEditable && - questionRequest.questions.length > 0 && - questionRequest.questions.every((_, index) => { - const answer = answers[index] ?? []; - return answer.some((item) => item.trim().length > 0); - }); - - const answerSummary = (questionRequest.answers ?? []) - .map((answer) => answer.join("、")) - .filter(Boolean) - .join(";"); - - return ( - <Box - sx={{ - borderRadius: 3, - overflow: "hidden", - border: `1px solid ${alpha("#fff", 0.72)}`, - bgcolor: alpha("#fff", 0.52), - boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, - backdropFilter: "blur(20px)", - position: "relative", - "&::before": { - content: '""', - position: "absolute", - inset: "10px auto 10px 0", - width: 3, - borderRadius: "0 999px 999px 0", - bgcolor: statusColor, - }, - }} - > - <Stack - direction="row" - spacing={1} - alignItems="center" - sx={{ - px: 1.5, - py: 1.25, - pl: 1.75, - borderBottom: `1px solid ${alpha("#000", 0.05)}`, - }} - > - <Box - sx={{ - width: 32, - height: 32, - borderRadius: "50%", - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: statusColor, - bgcolor: alpha(statusColor, 0.1), - border: `1px solid ${alpha(statusColor, 0.16)}`, - }} - > - <HelpOutlineRounded sx={{ fontSize: 21 }} /> - </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> - 需要补充信息 - </Typography> - </Box> - <Chip - size="small" - label={getQuestionStatusLabel(questionRequest.status)} - sx={{ - height: 24, - fontSize: "0.7rem", - fontWeight: 800, - borderRadius: "12px", - bgcolor: alpha(statusColor, 0.12), - color: statusColor, - "& .MuiChip-label": { px: 1 }, - }} - /> - </Stack> - - <Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}> - {questionRequest.questions.map((question, index) => { - const selectedAnswers = selected[index] ?? []; - const isCustomEnabled = question.custom !== false; - const isCustomSelected = - customSelected[index] === true || - (isCustomEnabled && question.options.length === 0); - const setQuestionAnswers = (nextAnswers: string[]) => { - setSelected((current) => ({ - ...current, - [index]: nextAnswers, - })); - }; - const setQuestionCustomSelected = (checked: boolean) => { - setCustomSelected((current) => ({ - ...current, - [index]: checked, - })); - }; - - return ( - <Box - key={`${question.header}-${index}`} - sx={{ - px: 1.25, - py: 1, - borderRadius: 2.5, - bgcolor: alpha("#000", 0.025), - border: `1px solid ${alpha("#000", 0.045)}`, - }} - > - <Typography variant="caption" color="text.secondary" fontWeight={800}> - {question.header || `问题 ${index + 1}`} - </Typography> - <Typography - variant="body2" - color="text.primary" - sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }} - > - {question.question} - </Typography> - - {question.options.length ? ( - <Stack spacing={0.75} sx={{ mt: 1 }}> - {question.options.map((option) => { - const checked = selectedAnswers.includes(option.label); - if (question.multiple) { - return ( - <FormControlLabel - key={option.label} - disabled={!isEditable || isSubmitting} - control={ - <Checkbox - size="small" - checked={checked} - onChange={(event) => { - if (event.target.checked) { - setQuestionAnswers([...selectedAnswers, option.label]); - } else { - setQuestionAnswers( - selectedAnswers.filter((item) => item !== option.label), - ); - } - }} - /> - } - label={ - <Box> - <Typography variant="body2" fontWeight={750}> - {option.label} - </Typography> - {option.description ? ( - <Typography variant="caption" color="text.secondary"> - {option.description} - </Typography> - ) : null} - </Box> - } - sx={{ alignItems: "flex-start", m: 0 }} - /> - ); - } - return ( - <Button - key={option.label} - size="small" - variant={checked ? "contained" : "outlined"} - disabled={!isEditable || isSubmitting} - onClick={() => { - setQuestionAnswers([option.label]); - setQuestionCustomSelected(false); - }} - startIcon={ - checked ? ( - <CheckCircleRounded fontSize="small" /> - ) : ( - <RadioButtonUncheckedRounded fontSize="small" /> - ) - } - sx={{ - justifyContent: "flex-start", - minHeight: 38, - borderRadius: 2, - textTransform: "none", - fontWeight: 800, - bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45), - borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22), - "&:hover": { - bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08), - }, - }} - > - <Box sx={{ textAlign: "left", minWidth: 0 }}> - <Typography variant="body2" fontWeight={800}> - {option.label} - </Typography> - {option.description ? ( - <Typography - variant="caption" - sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }} - > - {option.description} - </Typography> - ) : null} - </Box> - </Button> - ); - })} - {isCustomEnabled ? ( - question.multiple ? ( - <FormControlLabel - disabled={!isEditable || isSubmitting} - control={ - <Checkbox - size="small" - checked={isCustomSelected} - onChange={(event) => - setQuestionCustomSelected(event.target.checked) - } - sx={{ - p: 0.5, - color: alpha("#0288d1", 0.55), - "&.Mui-checked": { color: "#0288d1" }, - }} - /> - } - label={ - <Stack direction="row" spacing={0.75} alignItems="center"> - <EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} /> - <Typography variant="body2" fontWeight={800}> - 自定义回答 - </Typography> - </Stack> - } - sx={{ - alignItems: "center", - minHeight: 38, - m: 0, - px: 0.75, - py: 0.25, - borderRadius: 2, - border: `1px solid ${ - isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18) - }`, - bgcolor: isCustomSelected - ? alpha("#0288d1", 0.1) - : alpha("#fff", 0.45), - transition: "background-color 0.18s ease, border-color 0.18s ease", - "&:hover": { - bgcolor: isCustomSelected - ? alpha("#0288d1", 0.13) - : alpha("#0288d1", 0.07), - }, - "& .MuiFormControlLabel-label": { - color: isCustomSelected ? "#0277bd" : "text.primary", - }, - }} - /> - ) : ( - <Button - size="small" - variant={isCustomSelected ? "contained" : "outlined"} - disabled={!isEditable || isSubmitting} - onClick={() => { - setQuestionAnswers([]); - setQuestionCustomSelected(true); - }} - startIcon={ - isCustomSelected ? ( - <CheckCircleRounded fontSize="small" /> - ) : ( - <EditNoteRounded fontSize="small" /> - ) - } - sx={{ - justifyContent: "flex-start", - minHeight: 38, - borderRadius: 2, - textTransform: "none", - fontWeight: 800, - bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45), - borderColor: isCustomSelected - ? "#0288d1" - : alpha("#0288d1", 0.22), - "&:hover": { - bgcolor: isCustomSelected - ? "#0277bd" - : alpha("#0288d1", 0.08), - }, - }} - > - <Box sx={{ textAlign: "left", minWidth: 0 }}> - <Typography variant="body2" fontWeight={800}> - 自定义回答 - </Typography> - </Box> - </Button> - ) - ) : null} - </Stack> - ) : null} - - <Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit> - <Box - sx={{ - mt: 0.85, - px: 1.15, - py: 0.85, - borderRadius: 2.5, - bgcolor: alpha("#fff", 0.62), - border: `1px solid ${alpha("#fff", 0.82)}`, - boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`, - backdropFilter: "blur(18px)", - }} - > - <TextField - multiline - minRows={2} - maxRows={5} - fullWidth - variant="standard" - disabled={!isEditable || isSubmitting} - value={custom[index] ?? ""} - onChange={(event) => - setCustom((current) => ({ - ...current, - [index]: event.target.value, - })) - } - placeholder="输入自定义回答" - InputProps={{ - disableUnderline: true, - sx: { - alignItems: "flex-start", - fontSize: "0.88rem", - lineHeight: 1.55, - fontWeight: 500, - color: "text.primary", - "& textarea::placeholder": { - color: alpha(theme.palette.text.primary, 0.38), - opacity: 1, - }, - }, - }} - /> - </Box> - </Collapse> - </Box> - ); - })} - - {questionRequest.status === "answered" ? ( - <Typography - variant="caption" - color="success.main" - sx={{ - display: "block", - px: 1.25, - py: 0.75, - borderRadius: 2, - bgcolor: alpha(theme.palette.success.main, 0.07), - wordBreak: "break-word", - }} - > - 已回答{answerSummary ? `:${answerSummary}` : ""} - </Typography> - ) : null} - - {questionRequest.status === "rejected" ? ( - <Typography - variant="caption" - color="text.secondary" - sx={{ - display: "block", - px: 1.25, - py: 0.75, - borderRadius: 2, - bgcolor: alpha("#000", 0.035), - }} - > - 已跳过 - </Typography> - ) : null} - - {questionRequest.error ? ( - <Typography - variant="caption" - color="error.main" - sx={{ - display: "block", - px: 1.25, - py: 0.75, - borderRadius: 2, - bgcolor: alpha(theme.palette.error.main, 0.06), - wordBreak: "break-word", - }} - > - {questionRequest.error} - </Typography> - ) : null} - </Stack> - - {isEditable || isSubmitting ? ( - <Stack - direction="row" - spacing={1} - flexWrap="wrap" - useFlexGap - sx={{ px: 1.5, pb: 1.35, pl: 1.75 }} - > - <Button - size="small" - variant="outlined" - disabled={isSubmitting} - onClick={() => onReject(questionRequest.requestId)} - sx={{ - height: 34, - borderRadius: "17px", - px: 1.5, - fontWeight: 800, - fontSize: "0.78rem", - textTransform: "none", - color: "text.secondary", - borderColor: alpha(theme.palette.text.secondary, 0.22), - bgcolor: alpha("#fff", 0.45), - }} - > - 跳过 - </Button> - <Button - size="small" - variant="contained" - disableElevation - disabled={!canSubmit || isSubmitting} - onClick={() => onReply(questionRequest.requestId, answers)} - startIcon={ - isSubmitting ? ( - <CircularProgress size={14} color="inherit" /> - ) : ( - <CheckCircleRounded fontSize="small" /> - ) - } - sx={{ - minWidth: 104, - height: 34, - borderRadius: "17px", - bgcolor: "#0288d1", - fontWeight: 800, - fontSize: "0.78rem", - textTransform: "none", - boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`, - "&:hover": { - bgcolor: "#0277bd", - boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`, - }, - }} - > - 提交回答 - </Button> - </Stack> - ) : null} - </Box> - ); -}; - -const QuestionRequestGroup = ({ - questions, - onReply, - onReject, -}: { - questions: NonNullable<Message["questions"]>; - onReply: (requestId: string, answers: string[][]) => void; - onReject: (requestId: string) => void; -}) => ( - <Stack spacing={1}> - {questions.map((question) => ( - <QuestionRequestCard - key={question.requestId} - questionRequest={question} - onReply={onReply} - onReject={onReject} - /> - ))} - </Stack> -); - -const TodoPlanCard = ({ - todoUpdate, -}: { - todoUpdate: NonNullable<Message["todos"]>; -}) => { - const theme = useTheme(); - const total = todoUpdate.todos.length; - const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; - const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); - const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; - const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length; - const progress = total > 0 ? Math.round((completed / total) * 100) : 0; - const isAborted = cancelled > 0 && completed + cancelled === total; - const canCollapse = total > 4; - const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted); - const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos; - const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : []; - const hiddenCount = expanded ? 0 : collapsibleTodos.length; - const latestUpdatedAt = Math.max( - todoUpdate.createdAt, - ...todoUpdate.todos - .map((todo) => todo.updatedAt ?? todo.createdAt ?? 0) - .filter((value) => value > 0), - ); - const updatedAtLabel = - latestUpdatedAt > 0 - ? new Intl.DateTimeFormat("zh-CN", { - hour: "2-digit", - minute: "2-digit", - }).format(new Date(latestUpdatedAt)) - : undefined; - - const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => { - if (status === "completed") { - return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" }; - } - if (status === "in_progress") { - return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" }; - } - if (status === "cancelled") { - return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" }; - } - return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" }; - }; - - const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => { - if (priority === "high") return { label: "高优先级", color: "#8a5a00" }; - if (priority === "medium") return { label: "中优先级", color: "#9a6a16" }; - if (priority === "low") return { label: "低优先级", color: "#8d7960" }; - return undefined; - }; - - const statusSummary = isAborted - ? `${completed} 完成 / ${cancelled} 中止` - : [ - completed ? `${completed} 完成` : null, - running ? "1 进行中" : null, - pending ? `${pending} 待办` : null, - cancelled ? `${cancelled} 中止` : null, - ].filter(Boolean).join(" / ") || "等待任务"; - const renderTodoRow = ( - todo: NonNullable<Message["todos"]>["todos"][number], - index: number, - ) => { - const visual = getTodoVisual(todo.status); - const priority = getPriorityLabel(todo.priority); - return ( - <Stack - key={`${todo.id}-${index}`} - direction="row" - alignItems="flex-start" - spacing={1} - sx={{ - py: 0.8, - borderTop: `1px solid ${alpha("#00838f", 0.08)}`, - color: todo.status === "cancelled" ? "text.disabled" : "text.primary", - }} - > - <Box - sx={{ - width: 24, - height: 24, - borderRadius: 1.25, - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: visual.color, - bgcolor: alpha(visual.color, 0.08), - mt: 0.1, - }} - > - {visual.icon} - </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Typography - variant="body2" - sx={{ - minWidth: 0, - wordBreak: "break-word", - lineHeight: 1.45, - textDecoration: todo.status === "cancelled" ? "line-through" : undefined, - }} - > - {todo.content} - </Typography> - </Box> - <Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}> - {priority ? ( - <Chip - size="small" - label={priority.label} - sx={{ - height: 22, - borderRadius: "11px", - fontSize: "0.66rem", - fontWeight: 800, - color: priority.color, - bgcolor: alpha(priority.color, 0.045), - border: `1px solid ${alpha(priority.color, 0.16)}`, - "& .MuiChip-label": { px: 0.75 }, - }} - /> - ) : null} - <Chip - size="small" - label={visual.label} - sx={{ - height: 22, - borderRadius: "11px", - fontSize: "0.66rem", - fontWeight: 800, - color: visual.color, - bgcolor: alpha(visual.color, 0.08), - "& .MuiChip-label": { px: 0.75 }, - }} - /> - </Stack> - </Stack> - ); - }; - - if (total === 0) { - return null; - } - - return ( - <Box - sx={{ - borderRadius: 2, - overflow: "hidden", - border: `1px solid ${alpha("#00838f", 0.16)}`, - bgcolor: alpha("#f8fbfc", 0.82), - }} - > - <Stack - spacing={1} - role="button" - tabIndex={0} - onClick={() => { - if (canCollapse) { - setExpanded((value) => !value); - } - }} - onKeyDown={(event) => { - if (canCollapse && (event.key === "Enter" || event.key === " ")) { - event.preventDefault(); - setExpanded((value) => !value); - } - }} - sx={{ - px: 1.4, - py: 1.15, - cursor: canCollapse ? "pointer" : "default", - transition: "background-color 0.2s ease", - "&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined, - }} - > - <Stack direction="row" alignItems="center" spacing={1}> - <Box - sx={{ - width: 28, - height: 28, - borderRadius: 1.5, - display: "grid", - placeItems: "center", - flex: "0 0 auto", - color: "#00838f", - bgcolor: alpha("#00838f", 0.1), - border: `1px solid ${alpha("#00838f", 0.14)}`, - }} - > - <AssignmentTurnedInRounded sx={{ fontSize: 18 }} /> - </Box> - <Box sx={{ minWidth: 0, flex: 1 }}> - <Stack direction="row" alignItems="center" spacing={0.75}> - <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> - 会话任务 - </Typography> - <Chip - size="small" - label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"} - sx={{ - height: 20, - borderRadius: "10px", - fontSize: "0.66rem", - fontWeight: 800, - color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f", - bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08), - "& .MuiChip-label": { px: 0.75 }, - }} - /> - </Stack> - <Typography variant="caption" color="text.secondary"> - {statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""} - </Typography> - </Box> - {canCollapse ? ( - <IconButton - size="small" - aria-label={expanded ? "收起会话任务" : "展开会话任务"} - sx={{ - width: 28, - height: 28, - color: "text.secondary", - bgcolor: alpha("#000", 0.035), - "&:hover": { bgcolor: alpha("#000", 0.07) }, - }} - > - {expanded ? ( - <KeyboardArrowUpRounded sx={{ fontSize: 18 }} /> - ) : ( - <KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> - )} - </IconButton> - ) : null} - </Stack> - <Box - sx={{ - height: 6, - borderRadius: 999, - overflow: "hidden", - bgcolor: alpha("#00838f", 0.1), - }} - > - <Box - sx={{ - width: `${progress}%`, - height: "100%", - borderRadius: 999, - bgcolor: isAborted ? theme.palette.text.disabled : "#00838f", - transition: "width 0.25s ease", - }} - /> - </Box> - </Stack> - - <Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}> - {pinnedTodos.map((todo, index) => renderTodoRow(todo, index))} - {canCollapse ? ( - <Collapse in={expanded} timeout={220} unmountOnExit={false}> - <Stack spacing={0}> - {collapsibleTodos.map((todo, index) => - renderTodoRow(todo, index + pinnedTodos.length), - )} - </Stack> - </Collapse> - ) : null} - {hiddenCount > 0 ? ( - <Typography - variant="caption" - color="text.secondary" - sx={{ - pt: 0.8, - borderTop: `1px solid ${alpha("#00838f", 0.08)}`, - }} - > - 还有 {hiddenCount} 项,展开查看全部 - </Typography> - ) : null} - </Stack> - </Box> - ); -}; - export const AgentTurn = React.memo( ({ message, diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts new file mode 100644 index 0000000..56d1699 --- /dev/null +++ b/src/components/chat/hooks/agentChatSessionState.ts @@ -0,0 +1,457 @@ +import type { + AgentQuestionRequest, + AgentTodoUpdate, + PermissionReply, + StreamEvent, +} from "@/lib/chatStream"; +import type { + AgentPermissionRequest, + ChatProgress, + LoadedChatState, + Message, +} from "../GlobalChatbox.types"; +import { createId } from "../GlobalChatbox.utils"; + +export const createPersistedStateKey = (state: LoadedChatState) => + JSON.stringify({ + title: state.title ?? null, + isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, + sessionId: state.sessionId ?? null, + messages: state.messages, + }); + +export const upsertProgress = ( + progress: ChatProgress[] | undefined, + event: StreamEvent & { type: "progress" }, +) => { + const next = [...(progress ?? [])]; + const index = next.findIndex((item) => item.id === event.id); + const existing = index >= 0 ? next[index] : undefined; + const now = Date.now(); + const startedAt = event.startedAt ?? existing?.startedAt; + const isRunning = event.status === "running"; + const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now; + const elapsedMs = isRunning + ? event.elapsedMs ?? + existing?.elapsedMs ?? + (startedAt !== undefined ? Math.max(0, now - startedAt) : undefined) + : undefined; + const elapsedSnapshotAt = isRunning + ? event.elapsedMs !== undefined + ? now + : existing?.elapsedSnapshotAt ?? now + : undefined; + const durationMs = !isRunning + ? event.durationMs ?? + existing?.durationMs ?? + (startedAt !== undefined && endedAt !== undefined + ? Math.max(0, endedAt - startedAt) + : undefined) + : undefined; + const nextItem: ChatProgress = { + id: event.id, + phase: event.phase, + status: event.status, + title: event.title, + detail: event.detail, + startedAt, + endedAt, + elapsedMs, + elapsedSnapshotAt, + durationMs, + }; + if (index >= 0) { + next[index] = nextItem; + } else { + next.push(nextItem); + } + return next; +}; + +export const completeRunningProgress = (progress: ChatProgress[] | undefined) => + progress?.map((item) => { + if (item.status !== "running") { + return item; + } + const endedAt = Date.now(); + return { + ...item, + status: "completed" as const, + endedAt, + elapsedMs: undefined, + elapsedSnapshotAt: undefined, + durationMs: + item.durationMs ?? + (item.startedAt !== undefined + ? Math.max(0, endedAt - item.startedAt) + : item.elapsedMs), + }; + }); + +export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => + todoUpdate + ? { + ...todoUpdate, + todos: todoUpdate.todos.map((todo) => + todo.status === "pending" || todo.status === "in_progress" + ? { + ...todo, + status: "cancelled" as const, + updatedAt: Date.now(), + } + : todo, + ), + } + : undefined; + +export const upsertPermission = ( + permissions: AgentPermissionRequest[] | undefined, + event: StreamEvent & { type: "permission_request" }, +) => { + const next = [...(permissions ?? [])]; + const index = next.findIndex((item) => item.requestId === event.requestId); + const nextItem: AgentPermissionRequest = { + requestId: event.requestId, + sessionId: event.sessionId, + permission: event.permission, + patterns: event.patterns, + metadata: event.metadata, + always: event.always, + tool: event.tool, + createdAt: event.createdAt, + status: "pending", + }; + if (index >= 0) { + next[index] = { + ...next[index], + ...nextItem, + status: next[index].status === "submitting" ? "submitting" : nextItem.status, + }; + } else { + next.push(nextItem); + } + return next; +}; + +export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => { + if (reply === "always") return "approved_always"; + if (reply === "once") return "approved_once"; + return "rejected"; +}; + +export const isActionableQuestionRequest = (question: { + requestId: string; + tool?: AgentQuestionRequest["tool"]; +}) => Boolean(question.requestId && question.requestId !== question.tool?.callID); + +export const toQuestionRequest = ( + event: StreamEvent & { type: "question_request" }, + status: AgentQuestionRequest["status"] = "pending", +): AgentQuestionRequest => ({ + requestId: event.requestId, + sessionId: event.sessionId, + questions: event.questions, + tool: event.tool, + createdAt: event.createdAt, + status, +}); + +export const getQuestionContentSignature = ( + questions: AgentQuestionRequest["questions"], +) => + JSON.stringify( + questions.map((question) => ({ + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multiple: question.multiple ?? false, + custom: question.custom !== false, + })), + ); + +export const isSameQuestionRequest = ( + question: AgentQuestionRequest, + event: StreamEvent & { type: "question_request" }, +) => { + if (question.requestId === event.requestId) return true; + if (question.tool?.callID && event.tool?.callID) { + return question.tool.callID === event.tool.callID; + } + return ( + question.status === "pending" && + question.sessionId === event.sessionId && + getQuestionContentSignature(question.questions) === + getQuestionContentSignature(event.questions) + ); +}; + +export const isSameQuestionPair = ( + left: AgentQuestionRequest, + right: AgentQuestionRequest, +) => { + if (left.requestId === right.requestId) return true; + if (left.tool?.callID && right.tool?.callID) { + return left.tool.callID === right.tool.callID; + } + return ( + left.status === "pending" && + right.status === "pending" && + left.sessionId === right.sessionId && + getQuestionContentSignature(left.questions) === + getQuestionContentSignature(right.questions) + ); +}; + +export const dedupeQuestionsAcrossMessages = (messages: Message[]) => { + const seen: AgentQuestionRequest[] = []; + let changed = false; + const nextMessages = messages.map((message) => { + if (!message.questions?.length) { + return message; + } + const nextQuestions = message.questions.filter((question) => { + if (seen.some((existing) => isSameQuestionPair(existing, question))) { + changed = true; + return false; + } + seen.push(question); + return true; + }); + if (nextQuestions.length === message.questions.length) { + return message; + } + return { + ...message, + questions: nextQuestions.length ? nextQuestions : undefined, + }; + }); + return changed ? nextMessages : messages; +}; + +export const upsertQuestionAcrossMessages = ( + messages: Message[], + event: StreamEvent & { type: "question_request" }, + assistantMessageId: string, +) => { + let existing: AgentQuestionRequest | undefined; + for (const message of messages) { + const match = message.questions?.find((question) => + isSameQuestionRequest(question, event), + ); + if (match) { + existing = match; + break; + } + } + + const existingStatus: AgentQuestionRequest["status"] | undefined = + existing?.status === "submitting" ? "submitting" : undefined; + const nextQuestion = + existing && + isActionableQuestionRequest(existing) && + !isActionableQuestionRequest(event) + ? { + ...existing, + sessionId: event.sessionId, + questions: event.questions, + tool: event.tool ?? existing.tool, + createdAt: event.createdAt, + status: existingStatus ?? existing.status, + } + : toQuestionRequest(event, existingStatus ?? "pending"); + const targetMessageId = existing + ? messages.find((message) => + message.questions?.some((question) => isSameQuestionRequest(question, event)), + )?.id ?? assistantMessageId + : assistantMessageId; + + return messages.map((message) => { + const filteredQuestions = message.questions?.filter( + (question) => !isSameQuestionRequest(question, event), + ); + if (message.id !== targetMessageId) { + return filteredQuestions?.length === message.questions?.length + ? message + : { + ...message, + questions: filteredQuestions?.length ? filteredQuestions : undefined, + }; + } + + const nextQuestions = [...(filteredQuestions ?? []), nextQuestion]; + return { + ...message, + questions: nextQuestions, + }; + }); +}; + +export const applyQuestionResponse = ( + questions: AgentQuestionRequest[] | undefined, + event: StreamEvent & { type: "question_response" }, +) => + (questions ?? []).map((question) => + question.requestId === event.requestId + ? { + ...question, + status: event.rejected ? "rejected" as const : "answered" as const, + answers: event.answers ?? question.answers, + repliedAt: Date.now(), + error: undefined, + } + : question, + ); + +export const createTodoUpdateFromEvent = ( + event: StreamEvent & { type: "todo_update" }, +): AgentTodoUpdate => ({ + sessionId: event.sessionId, + messageId: event.messageId, + todos: event.todos, + createdAt: event.createdAt, +}); + +export const normalizeSessionTodos = ( + messages: Message[], + nextTodoUpdate?: AgentTodoUpdate, + targetAssistantMessageId?: string, +) => { + let latestTodoUpdate = nextTodoUpdate; + if (!latestTodoUpdate) { + for (const message of messages) { + if (message.todos) { + latestTodoUpdate = message.todos; + } + } + } + + if (!latestTodoUpdate) { + return messages; + } + + const targetMessageId = + targetAssistantMessageId ?? + [...messages].reverse().find((message) => message.role === "assistant")?.id; + if (!targetMessageId) { + return messages; + } + + let changed = false; + const nextMessages = messages.map((message) => { + if (message.id === targetMessageId) { + if (message.todos === latestTodoUpdate) { + return message; + } + changed = true; + return { + ...message, + todos: latestTodoUpdate, + }; + } + if (!message.todos) { + return message; + } + changed = true; + return { + ...message, + todos: undefined, + }; + }); + + return changed ? nextMessages : messages; +}; + +export const rejectOpenPermissionsAfterAbort = ( + permissions: AgentPermissionRequest[] | undefined, +) => { + if (!permissions?.length) return permissions; + let changed = false; + const nextPermissions = permissions.map((permission) => { + if ( + permission.status !== "pending" && + permission.status !== "submitting" && + permission.status !== "error" + ) { + return permission; + } + changed = true; + return { + ...permission, + status: "rejected" as const, + repliedAt: Date.now(), + error: undefined, + }; + }); + return changed ? nextPermissions : permissions; +}; + +export const rejectOpenQuestionsAfterAbort = ( + questions: AgentQuestionRequest[] | undefined, +) => { + if (!questions?.length) return questions; + let changed = false; + const nextQuestions = questions.map((question) => { + if ( + question.status !== "pending" && + question.status !== "submitting" && + question.status !== "error" + ) { + return question; + } + changed = true; + return { + ...question, + status: "rejected" as const, + repliedAt: Date.now(), + error: undefined, + }; + }); + return changed ? nextQuestions : questions; +}; + +export const finalizeAssistantMessageAfterAbort = (message: Message): Message => { + const completedProgress = completeRunningProgress(message.progress); + const cancelledTodos = cancelRunningTodos(message.todos); + const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions); + const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); + const hasVisibleOutput = + message.content.trim().length > 0 || + Boolean(message.artifacts?.length) || + Boolean(rejectedPermissions?.length) || + Boolean(rejectedQuestions?.length) || + Boolean(completedProgress?.length) || + Boolean(cancelledTodos); + + if (!hasVisibleOutput) { + return message; + } + + return { + ...message, + content: message.content || "⚠️ **请求已中断**", + isError: true, + progress: completedProgress, + permissions: rejectedPermissions, + questions: rejectedQuestions, + todos: cancelledTodos, + }; +}; + +export const createUserMessage = (content: string): Message => { + const id = createId(); + return { + id, + role: "user", + content, + }; +}; + +export const createAssistantMessage = (): Message => ({ + id: createId(), + role: "assistant", + content: "", +}); + diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx new file mode 100644 index 0000000..3403ba7 --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { useAgentChatSession } from "./useAgentChatSession"; +import { + abortAgentChat, + forkAgentChat, + replyAgentPermission, + replyAgentQuestion, + resumeAgentChatStream, + streamAgentChat, +} from "@/lib/chatStream"; +import type { StreamEvent } from "@/lib/chatStream"; + +jest.mock("@/lib/chatStream", () => ({ + abortAgentChat: jest.fn(async () => undefined), + forkAgentChat: jest.fn(async () => "forked-session"), + replyAgentPermission: jest.fn(async () => undefined), + replyAgentQuestion: jest.fn(async () => undefined), + resumeAgentChatStream: jest.fn(async () => undefined), + streamAgentChat: jest.fn(async () => undefined), +})); + +const listChatSessions = jest.fn(); +const deleteChatSession = jest.fn(); +const saveActiveChatState = jest.fn(); +const updateChatSessionTitle = jest.fn(); + +jest.mock("../chatStorage", () => ({ + createEmptyChatState: jest.fn(() => ({ + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + })), + deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), + listChatSessions: (...args: unknown[]) => listChatSessions(...args), + loadChatSessionById: jest.fn(async () => ({ + title: "已存在会话", + isTitleManuallyEdited: false, + messages: [], + sessionId: "session-loaded", + })), + saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), + updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), +})); + +describe("useAgentChatSession", () => { + beforeEach(() => { + listChatSessions.mockReset(); + deleteChatSession.mockReset(); + saveActiveChatState.mockReset(); + updateChatSessionTitle.mockReset(); + jest.mocked(abortAgentChat).mockReset(); + jest.mocked(forkAgentChat).mockReset(); + jest.mocked(replyAgentPermission).mockReset(); + jest.mocked(replyAgentQuestion).mockReset(); + jest.mocked(resumeAgentChatStream).mockReset(); + jest.mocked(streamAgentChat).mockReset(); + jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); + jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); + jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); + jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); + jest.mocked(streamAgentChat).mockImplementation(async () => undefined); + deleteChatSession.mockImplementation(async () => undefined); + saveActiveChatState.mockImplementation(async (state) => state.sessionId); + updateChatSessionTitle.mockImplementation(async () => undefined); + }); + +describe("useAgentChatSession actions", () => { + it("tracks permission requests and submits replies", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise<void>(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + void result.current.sendPrompt("删除临时文件"); + await Promise.resolve(); + }); + + act(() => { + emitStreamEvent?.({ + type: "permission_request", + sessionId: "session-1", + requestId: "perm-1", + permission: "bash", + patterns: ["rm *"], + metadata: { command: "rm tmp.txt" }, + always: ["rm *"], + createdAt: 123, + }); + }); + + expect(result.current.messages.at(-1)?.permissions).toEqual([ + expect.objectContaining({ + requestId: "perm-1", + sessionId: "session-1", + status: "pending", + }), + ]); + + await act(async () => { + await result.current.replyPermission("perm-1", "once"); + }); + + expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once"); + expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual( + expect.objectContaining({ + requestId: "perm-1", + status: "approved_once", + }), + ); + }); + + it("finalizes running progress when aborting an active prompt", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat).mockImplementationOnce( + ({ onEvent, signal }) => + new Promise<void>((_, reject) => { + onEvent({ + type: "progress", + sessionId: "session-1", + id: "request-received", + phase: "start", + status: "running", + title: "开始分析", + startedAt: 1000, + } satisfies StreamEvent); + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "分析水位", + status: "in_progress", + }, + { + id: "todo-2", + content: "生成建议", + status: "pending", + }, + ], + createdAt: 1001, + } satisfies StreamEvent); + onEvent({ + type: "permission_request", + sessionId: "session-1", + requestId: "perm-abort", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + always: ["npm test"], + createdAt: 1002, + } satisfies StreamEvent); + onEvent({ + type: "question_request", + sessionId: "session-1", + requestId: "question-abort", + questions: [ + { + header: "范围", + question: "请选择范围", + options: [{ label: "城区", description: "中心城区" }], + }, + ], + createdAt: 1003, + } satisfies StreamEvent); + + signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }), + ); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.sendPrompt("测试中断"); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + act(() => { + result.current.abort(); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.messages.at(-1)).toEqual( + expect.objectContaining({ + role: "assistant", + content: "⚠️ **请求已中断**", + isError: true, + progress: [ + expect.objectContaining({ + id: "request-received", + status: "completed", + durationMs: expect.any(Number), + endedAt: expect.any(Number), + }), + ], + todos: expect.objectContaining({ + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "cancelled", + updatedAt: expect.any(Number), + }), + expect.objectContaining({ + id: "todo-2", + status: "cancelled", + updatedAt: expect.any(Number), + }), + ], + }), + permissions: [ + expect.objectContaining({ + requestId: "perm-abort", + status: "rejected", + repliedAt: expect.any(Number), + error: undefined, + }), + ], + questions: [ + expect.objectContaining({ + requestId: "question-abort", + status: "rejected", + repliedAt: expect.any(Number), + error: undefined, + }), + ], + }), + ); + expect(abortAgentChat).toHaveBeenCalledWith("session-1"); + }); + + it("ignores generated session titles after the title was edited manually", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "session_title", + sessionId: "session-1", + title: "自动标题", + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.switchSession("session-loaded"); + }); + + await act(async () => { + await result.current.renameSession("session-loaded", "手动标题"); + }); + + await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled()); + + await act(async () => { + await result.current.sendPrompt("帮我分析一下"); + }); + + expect(result.current.sessionTitle).toBe("手动标题"); + expect(updateChatSessionTitle).not.toHaveBeenCalledWith( + "session-loaded", + "自动标题", + expect.anything(), + ); + }); + + it("does not apply a late generated title to a newly created session", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + let resolveStream: (() => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise<void>((resolve) => { + resolveStream = resolve; + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + void result.current.sendPrompt("帮我分析一下"); + await Promise.resolve(); + }); + + act(() => { + emitStreamEvent?.({ + type: "done", + sessionId: "old-session", + }); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + act(() => { + result.current.createSession(); + }); + + expect(result.current.sessionTitle).toBe("新对话"); + + await act(async () => { + emitStreamEvent?.({ + type: "session_title", + sessionId: "old-session", + title: "旧请求标题", + }); + resolveStream?.(); + await Promise.resolve(); + }); + + expect(result.current.sessionTitle).toBe("新对话"); + expect(updateChatSessionTitle).toHaveBeenCalledWith( + "old-session", + "旧请求标题", + { isTitleManuallyEdited: false }, + ); + }); + + it("asks the backend to undo the previous user turn before regenerating", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("重新分析压力异常"); + }); + const assistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.regenerate(assistantMessageId); + }); + + expect(streamAgentChat).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: "重新分析压力异常", + regenerateFromMessageIndex: 0, + }), + ); + }); + + it("replaces the current chain when regenerating a middle assistant message", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("第一轮"); + }); + + await act(async () => { + await result.current.sendPrompt("第二轮"); + }); + + const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.regenerate(firstAssistantMessageId); + }); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: "user", + content: "第一轮", + }), + ); + expect(result.current.messages[1]).toEqual( + expect.objectContaining({ + role: "assistant", + content: "", + }), + ); + expect(streamAgentChat).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + message: "第一轮", + regenerateFromMessageIndex: 0, + }), + ); + }); + + it("forks a copied conversation from an assistant message", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("第一轮"); + }); + + const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; + + await act(async () => { + await result.current.createBranch(firstAssistantMessageId); + }); + + expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2); + expect(result.current.activeSessionId).toBe("forked-session"); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: "user", + content: "第一轮", + }), + ); + expect(result.current.messages[1]).toEqual( + expect.objectContaining({ + role: "assistant", + }), + ); + expect(streamAgentChat).toHaveBeenCalledTimes(1); + }); +}); +}); diff --git a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx new file mode 100644 index 0000000..4601ec0 --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx @@ -0,0 +1,791 @@ +"use client"; + +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { useAgentChatSession } from "./useAgentChatSession"; +import { + abortAgentChat, + forkAgentChat, + replyAgentPermission, + replyAgentQuestion, + resumeAgentChatStream, + streamAgentChat, +} from "@/lib/chatStream"; +import type { StreamEvent } from "@/lib/chatStream"; + +jest.mock("@/lib/chatStream", () => ({ + abortAgentChat: jest.fn(async () => undefined), + forkAgentChat: jest.fn(async () => "forked-session"), + replyAgentPermission: jest.fn(async () => undefined), + replyAgentQuestion: jest.fn(async () => undefined), + resumeAgentChatStream: jest.fn(async () => undefined), + streamAgentChat: jest.fn(async () => undefined), +})); + +const listChatSessions = jest.fn(); +const deleteChatSession = jest.fn(); +const saveActiveChatState = jest.fn(); +const updateChatSessionTitle = jest.fn(); + +jest.mock("../chatStorage", () => ({ + createEmptyChatState: jest.fn(() => ({ + title: undefined, + isTitleManuallyEdited: false, + messages: [], + sessionId: undefined, + })), + deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), + listChatSessions: (...args: unknown[]) => listChatSessions(...args), + loadChatSessionById: jest.fn(async () => ({ + title: "已存在会话", + isTitleManuallyEdited: false, + messages: [], + sessionId: "session-loaded", + })), + saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), + updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), +})); + +describe("useAgentChatSession", () => { + beforeEach(() => { + listChatSessions.mockReset(); + deleteChatSession.mockReset(); + saveActiveChatState.mockReset(); + updateChatSessionTitle.mockReset(); + jest.mocked(abortAgentChat).mockReset(); + jest.mocked(forkAgentChat).mockReset(); + jest.mocked(replyAgentPermission).mockReset(); + jest.mocked(replyAgentQuestion).mockReset(); + jest.mocked(resumeAgentChatStream).mockReset(); + jest.mocked(streamAgentChat).mockReset(); + jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); + jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); + jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); + jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); + jest.mocked(streamAgentChat).mockImplementation(async () => undefined); + deleteChatSession.mockImplementation(async () => undefined); + saveActiveChatState.mockImplementation(async (state) => state.sessionId); + updateChatSessionTitle.mockImplementation(async () => undefined); + }); + +describe("useAgentChatSession lifecycle and resume", () => { + it("does not add a new empty session to history until there is actual chat content", async () => { + listChatSessions.mockResolvedValue([]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.createSession(); + }); + + await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); + expect(result.current.chatSessions).toEqual([]); + expect(result.current.activeSessionId).toBeUndefined(); + expect(result.current.messages).toEqual([]); + expect(result.current.isStreaming).toBe(false); + expect(listChatSessions).toHaveBeenCalledTimes(1); + }); + + it("keeps existing history entries when creating a blank new session", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-1", + title: "已有会话", + createdAt: 1, + updatedAt: 1, + }, + ]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.createSession(); + }); + + expect(result.current.chatSessions).toEqual([ + { + id: "session-1", + title: "已有会话", + createdAt: 1, + updatedAt: 1, + }, + ]); + }); + + it("removes a deleted history entry before the backend delete finishes", async () => { + const initialSessions = [ + { + id: "session-1", + title: "第一段会话", + createdAt: 2, + updatedAt: 2, + }, + { + id: "session-2", + title: "第二段会话", + createdAt: 1, + updatedAt: 1, + }, + ]; + let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined; + + listChatSessions.mockResolvedValue(initialSessions); + deleteChatSession.mockImplementationOnce( + () => + new Promise<string | undefined>((resolve) => { + resolveDelete = resolve; + }), + ); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + act(() => { + void result.current.removeSession("session-2"); + }); + + expect(result.current.chatSessions).toEqual([ + expect.objectContaining({ id: "session-1" }), + ]); + + listChatSessions.mockResolvedValue([ + { + id: "session-1", + title: "第一段会话", + createdAt: 2, + updatedAt: 2, + }, + ]); + + await act(async () => { + resolveDelete?.(); + await Promise.resolve(); + }); + + await waitFor(() => + expect(result.current.chatSessions).toEqual([ + expect.objectContaining({ id: "session-1" }), + ]), + ); + }); + + it("persists a new conversation only after the stream is done", async () => { + listChatSessions.mockResolvedValue([]); + let emitStreamEvent: ((event: StreamEvent) => void) | undefined; + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + emitStreamEvent = onEvent; + await new Promise<void>(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + jest.useFakeTimers(); + try { + await act(async () => { + void result.current.sendPrompt("第一条消息"); + await Promise.resolve(); + }); + + expect(result.current.isStreaming).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).not.toHaveBeenCalled(); + + act(() => { + emitStreamEvent?.({ + type: "token", + sessionId: "chat-stream-1", + content: "收到", + }); + }); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(saveActiveChatState).not.toHaveBeenCalled(); + + act(() => { + emitStreamEvent?.({ + type: "done", + sessionId: "chat-stream-1", + }); + }); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1)); + expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ + sessionId: "chat-stream-1", + messages: [ + expect.objectContaining({ role: "user", content: "第一条消息" }), + expect.objectContaining({ role: "assistant", content: "收到" }), + ], + }); + } finally { + jest.useRealTimers(); + } + }); + + it("shows shared todo state only on the latest assistant message in a session", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "in_progress", + }, + ], + createdAt: 1000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "completed", + }, + { + id: "todo-2", + content: "更新任务状态", + status: "in_progress", + }, + ], + createdAt: 2000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("创建任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("更新任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + const assistantMessages = result.current.messages.filter( + (message) => message.role === "assistant", + ); + + expect(assistantMessages).toHaveLength(2); + expect(assistantMessages[0].todos).toBeUndefined(); + expect(assistantMessages[1].todos).toEqual( + expect.objectContaining({ + sessionId: "session-1", + createdAt: 2000, + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "completed", + }), + expect.objectContaining({ + id: "todo-2", + status: "in_progress", + }), + ], + }), + ); + }); + + it("hydrates a backend streaming session and resumes its stream", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + runStatus: "running", + }, + ]); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.activeSessionId).toBe("session-loaded"); + expect(resumeAgentChatStream).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-loaded", + }), + ); + }); + + it("updates resumed messages from state, token, and done events", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { id: "a1", role: "assistant", content: "已有" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "token", + sessionId: "session-loaded", + content: "输出", + }); + onEvent({ + type: "done", + sessionId: "session-loaded", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }), + expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }), + ]); + }); + + it("applies question responses to the message that owns the request", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "q-1", + sessionId: "session-loaded", + questions: [ + { + header: "范围", + question: "选择范围", + options: [], + custom: true, + }, + ], + createdAt: 123, + status: "pending", + }, + ], + }, + { id: "a2", role: "assistant", content: "后续消息" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_response", + sessionId: "session-loaded", + requestId: "q-1", + answers: [["城区"]], + rejected: false, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect(result.current.messages[1].questions?.[0]).toEqual( + expect.objectContaining({ + requestId: "q-1", + status: "answered", + answers: [["城区"]], + }), + ); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("deduplicates question requests across assistant messages", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "question-1", + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending", + }, + ], + }, + { id: "a2", role: "assistant", content: "后续消息" }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_request", + sessionId: "session-loaded", + requestId: "call-1", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 456, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + const allQuestions = result.current.messages.flatMap( + (message) => message.questions ?? [], + ); + expect(allQuestions).toHaveLength(1); + expect(result.current.messages[1].questions?.[0]).toEqual( + expect.objectContaining({ + requestId: "question-1", + tool: expect.objectContaining({ callID: "call-1" }), + }), + ); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [ + { + requestId: "question-1", + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending", + }, + ], + }, + ], + isStreaming: true, + runStatus: "running", + }); + onEvent({ + type: "question_request", + sessionId: "session-loaded", + requestId: "call-1", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 456, + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + const allQuestions = result.current.messages.flatMap( + (message) => message.questions ?? [], + ); + expect(allQuestions).toHaveLength(1); + expect(allQuestions[0]).toEqual( + expect.objectContaining({ + requestId: "question-1", + tool: expect.objectContaining({ callID: "call-1" }), + }), + ); + }); + + it("deduplicates persisted duplicate questions from state events", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + const duplicateQuestion = { + sessionId: "session-loaded", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], + tool: { + messageID: "message-1", + callID: "call-1", + }, + createdAt: 123, + status: "pending" as const, + }; + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "state", + sessionId: "session-loaded", + messages: [ + { id: "u1", role: "user", content: "继续分析" }, + { + id: "a1", + role: "assistant", + content: "需要确认", + questions: [{ ...duplicateQuestion, requestId: "question-1" }], + }, + { + id: "a2", + role: "assistant", + content: "后续消息", + questions: [{ ...duplicateQuestion, requestId: "call-1" }], + }, + ], + isStreaming: true, + runStatus: "running", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + expect( + result.current.messages.flatMap((message) => message.questions ?? []), + ).toHaveLength(1); + expect(result.current.messages[1].questions).toHaveLength(1); + expect(result.current.messages[2].questions).toBeUndefined(); + }); + + it("aborts a resumed streaming session through the backend abort endpoint", async () => { + listChatSessions.mockResolvedValue([ + { + id: "session-streaming", + title: "运行中", + createdAt: 1, + updatedAt: 2, + isStreaming: true, + }, + ]); + jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => { + await new Promise<void>(() => undefined); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + act(() => { + result.current.abort(); + }); + + expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); + }); + +}); +}); diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index edc75cf..55c7215 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -1,1194 +1,2 @@ -"use client"; - -import { act, renderHook, waitFor } from "@testing-library/react"; - -import { useAgentChatSession } from "./useAgentChatSession"; -import { - abortAgentChat, - forkAgentChat, - replyAgentPermission, - replyAgentQuestion, - resumeAgentChatStream, - streamAgentChat, -} from "@/lib/chatStream"; -import type { StreamEvent } from "@/lib/chatStream"; - -jest.mock("@/lib/chatStream", () => ({ - abortAgentChat: jest.fn(async () => undefined), - forkAgentChat: jest.fn(async () => "forked-session"), - replyAgentPermission: jest.fn(async () => undefined), - replyAgentQuestion: jest.fn(async () => undefined), - resumeAgentChatStream: jest.fn(async () => undefined), - streamAgentChat: jest.fn(async () => undefined), -})); - -const listChatSessions = jest.fn(); -const deleteChatSession = jest.fn(); -const saveActiveChatState = jest.fn(); -const updateChatSessionTitle = jest.fn(); - -jest.mock("../chatStorage", () => ({ - createEmptyChatState: jest.fn(() => ({ - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - })), - deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), - listChatSessions: (...args: unknown[]) => listChatSessions(...args), - loadChatSessionById: jest.fn(async () => ({ - title: "已存在会话", - isTitleManuallyEdited: false, - messages: [], - sessionId: "session-loaded", - })), - saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), - updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), -})); - -describe("useAgentChatSession", () => { - beforeEach(() => { - listChatSessions.mockReset(); - deleteChatSession.mockReset(); - saveActiveChatState.mockReset(); - updateChatSessionTitle.mockReset(); - jest.mocked(abortAgentChat).mockReset(); - jest.mocked(forkAgentChat).mockReset(); - jest.mocked(replyAgentPermission).mockReset(); - jest.mocked(replyAgentQuestion).mockReset(); - jest.mocked(resumeAgentChatStream).mockReset(); - jest.mocked(streamAgentChat).mockReset(); - jest.mocked(abortAgentChat).mockImplementation(async () => undefined); - jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); - jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); - jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); - jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); - jest.mocked(streamAgentChat).mockImplementation(async () => undefined); - deleteChatSession.mockImplementation(async () => undefined); - saveActiveChatState.mockImplementation(async (state) => state.sessionId); - updateChatSessionTitle.mockImplementation(async () => undefined); - }); - - it("does not add a new empty session to history until there is actual chat content", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.createSession(); - }); - - await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); - expect(result.current.chatSessions).toEqual([]); - expect(result.current.activeSessionId).toBeUndefined(); - expect(result.current.messages).toEqual([]); - expect(result.current.isStreaming).toBe(false); - expect(listChatSessions).toHaveBeenCalledTimes(1); - }); - - it("keeps existing history entries when creating a blank new session", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-1", - title: "已有会话", - createdAt: 1, - updatedAt: 1, - }, - ]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.createSession(); - }); - - expect(result.current.chatSessions).toEqual([ - { - id: "session-1", - title: "已有会话", - createdAt: 1, - updatedAt: 1, - }, - ]); - }); - - it("removes a deleted history entry before the backend delete finishes", async () => { - const initialSessions = [ - { - id: "session-1", - title: "第一段会话", - createdAt: 2, - updatedAt: 2, - }, - { - id: "session-2", - title: "第二段会话", - createdAt: 1, - updatedAt: 1, - }, - ]; - let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined; - - listChatSessions.mockResolvedValue(initialSessions); - deleteChatSession.mockImplementationOnce( - () => - new Promise<string | undefined>((resolve) => { - resolveDelete = resolve; - }), - ); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.removeSession("session-2"); - }); - - expect(result.current.chatSessions).toEqual([ - expect.objectContaining({ id: "session-1" }), - ]); - - listChatSessions.mockResolvedValue([ - { - id: "session-1", - title: "第一段会话", - createdAt: 2, - updatedAt: 2, - }, - ]); - - await act(async () => { - resolveDelete?.(); - await Promise.resolve(); - }); - - await waitFor(() => - expect(result.current.chatSessions).toEqual([ - expect.objectContaining({ id: "session-1" }), - ]), - ); - }); - - it("persists a new conversation only after the stream is done", async () => { - listChatSessions.mockResolvedValue([]); - let emitStreamEvent: ((event: StreamEvent) => void) | undefined; - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - emitStreamEvent = onEvent; - await new Promise<void>(() => undefined); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - jest.useFakeTimers(); - try { - await act(async () => { - void result.current.sendPrompt("第一条消息"); - await Promise.resolve(); - }); - - expect(result.current.isStreaming).toBe(true); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - expect(saveActiveChatState).not.toHaveBeenCalled(); - - act(() => { - emitStreamEvent?.({ - type: "token", - sessionId: "chat-stream-1", - content: "收到", - }); - }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - expect(saveActiveChatState).not.toHaveBeenCalled(); - - act(() => { - emitStreamEvent?.({ - type: "done", - sessionId: "chat-stream-1", - }); - }); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1)); - expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ - sessionId: "chat-stream-1", - messages: [ - expect.objectContaining({ role: "user", content: "第一条消息" }), - expect.objectContaining({ role: "assistant", content: "收到" }), - ], - }); - } finally { - jest.useRealTimers(); - } - }); - - it("shows shared todo state only on the latest assistant message in a session", async () => { - listChatSessions.mockResolvedValue([]); - jest.mocked(streamAgentChat) - .mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "todo_update", - sessionId: "session-1", - todos: [ - { - id: "todo-1", - content: "创建任务列表", - status: "in_progress", - }, - ], - createdAt: 1000, - }); - onEvent({ - type: "done", - sessionId: "session-1", - }); - }) - .mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "todo_update", - sessionId: "session-1", - todos: [ - { - id: "todo-1", - content: "创建任务列表", - status: "completed", - }, - { - id: "todo-2", - content: "更新任务状态", - status: "in_progress", - }, - ], - createdAt: 2000, - }); - onEvent({ - type: "done", - sessionId: "session-1", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("创建任务"); - }); - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("更新任务"); - }); - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - const assistantMessages = result.current.messages.filter( - (message) => message.role === "assistant", - ); - - expect(assistantMessages).toHaveLength(2); - expect(assistantMessages[0].todos).toBeUndefined(); - expect(assistantMessages[1].todos).toEqual( - expect.objectContaining({ - sessionId: "session-1", - createdAt: 2000, - todos: [ - expect.objectContaining({ - id: "todo-1", - status: "completed", - }), - expect.objectContaining({ - id: "todo-2", - status: "in_progress", - }), - ], - }), - ); - }); - - it("hydrates a backend streaming session and resumes its stream", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - runStatus: "running", - }, - ]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - expect(result.current.isStreaming).toBe(true); - expect(result.current.activeSessionId).toBe("session-loaded"); - expect(resumeAgentChatStream).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "session-loaded", - }), - ); - }); - - it("updates resumed messages from state, token, and done events", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { id: "a1", role: "assistant", content: "已有" }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "token", - sessionId: "session-loaded", - content: "输出", - }); - onEvent({ - type: "done", - sessionId: "session-loaded", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - expect(result.current.messages).toEqual([ - expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }), - expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }), - ]); - }); - - it("applies question responses to the message that owns the request", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [ - { - requestId: "q-1", - sessionId: "session-loaded", - questions: [ - { - header: "范围", - question: "选择范围", - options: [], - custom: true, - }, - ], - createdAt: 123, - status: "pending", - }, - ], - }, - { id: "a2", role: "assistant", content: "后续消息" }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "question_response", - sessionId: "session-loaded", - requestId: "q-1", - answers: [["城区"]], - rejected: false, - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - expect(result.current.messages[1].questions?.[0]).toEqual( - expect.objectContaining({ - requestId: "q-1", - status: "answered", - answers: [["城区"]], - }), - ); - expect(result.current.messages[2].questions).toBeUndefined(); - }); - - it("deduplicates question requests across assistant messages", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [ - { - requestId: "question-1", - sessionId: "session-loaded", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 123, - status: "pending", - }, - ], - }, - { id: "a2", role: "assistant", content: "后续消息" }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "question_request", - sessionId: "session-loaded", - requestId: "call-1", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 456, - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - const allQuestions = result.current.messages.flatMap( - (message) => message.questions ?? [], - ); - expect(allQuestions).toHaveLength(1); - expect(result.current.messages[1].questions?.[0]).toEqual( - expect.objectContaining({ - requestId: "question-1", - tool: expect.objectContaining({ callID: "call-1" }), - }), - ); - expect(result.current.messages[2].questions).toBeUndefined(); - }); - - it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [ - { - requestId: "question-1", - sessionId: "session-loaded", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 123, - status: "pending", - }, - ], - }, - ], - isStreaming: true, - runStatus: "running", - }); - onEvent({ - type: "question_request", - sessionId: "session-loaded", - requestId: "call-1", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 456, - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - const allQuestions = result.current.messages.flatMap( - (message) => message.questions ?? [], - ); - expect(allQuestions).toHaveLength(1); - expect(allQuestions[0]).toEqual( - expect.objectContaining({ - requestId: "question-1", - tool: expect.objectContaining({ callID: "call-1" }), - }), - ); - }); - - it("deduplicates persisted duplicate questions from state events", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - const duplicateQuestion = { - sessionId: "session-loaded", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], - tool: { - messageID: "message-1", - callID: "call-1", - }, - createdAt: 123, - status: "pending" as const, - }; - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "state", - sessionId: "session-loaded", - messages: [ - { id: "u1", role: "user", content: "继续分析" }, - { - id: "a1", - role: "assistant", - content: "需要确认", - questions: [{ ...duplicateQuestion, requestId: "question-1" }], - }, - { - id: "a2", - role: "assistant", - content: "后续消息", - questions: [{ ...duplicateQuestion, requestId: "call-1" }], - }, - ], - isStreaming: true, - runStatus: "running", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - expect( - result.current.messages.flatMap((message) => message.questions ?? []), - ).toHaveLength(1); - expect(result.current.messages[1].questions).toHaveLength(1); - expect(result.current.messages[2].questions).toBeUndefined(); - }); - - it("aborts a resumed streaming session through the backend abort endpoint", async () => { - listChatSessions.mockResolvedValue([ - { - id: "session-streaming", - title: "运行中", - createdAt: 1, - updatedAt: 2, - isStreaming: true, - }, - ]); - jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => { - await new Promise<void>(() => undefined); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isStreaming).toBe(true)); - - act(() => { - result.current.abort(); - }); - - expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); - }); - - it("tracks permission requests and submits replies", async () => { - listChatSessions.mockResolvedValue([]); - let emitStreamEvent: ((event: StreamEvent) => void) | undefined; - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - emitStreamEvent = onEvent; - await new Promise<void>(() => undefined); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - void result.current.sendPrompt("删除临时文件"); - await Promise.resolve(); - }); - - act(() => { - emitStreamEvent?.({ - type: "permission_request", - sessionId: "session-1", - requestId: "perm-1", - permission: "bash", - patterns: ["rm *"], - metadata: { command: "rm tmp.txt" }, - always: ["rm *"], - createdAt: 123, - }); - }); - - expect(result.current.messages.at(-1)?.permissions).toEqual([ - expect.objectContaining({ - requestId: "perm-1", - sessionId: "session-1", - status: "pending", - }), - ]); - - await act(async () => { - await result.current.replyPermission("perm-1", "once"); - }); - - expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once"); - expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual( - expect.objectContaining({ - requestId: "perm-1", - status: "approved_once", - }), - ); - }); - - it("finalizes running progress when aborting an active prompt", async () => { - listChatSessions.mockResolvedValue([]); - jest.mocked(streamAgentChat).mockImplementationOnce( - ({ onEvent, signal }) => - new Promise<void>((_, reject) => { - onEvent({ - type: "progress", - sessionId: "session-1", - id: "request-received", - phase: "start", - status: "running", - title: "开始分析", - startedAt: 1000, - } satisfies StreamEvent); - onEvent({ - type: "todo_update", - sessionId: "session-1", - todos: [ - { - id: "todo-1", - content: "分析水位", - status: "in_progress", - }, - { - id: "todo-2", - content: "生成建议", - status: "pending", - }, - ], - createdAt: 1001, - } satisfies StreamEvent); - onEvent({ - type: "permission_request", - sessionId: "session-1", - requestId: "perm-abort", - permission: "bash", - patterns: ["npm test"], - metadata: { command: "npm test" }, - always: ["npm test"], - createdAt: 1002, - } satisfies StreamEvent); - onEvent({ - type: "question_request", - sessionId: "session-1", - requestId: "question-abort", - questions: [ - { - header: "范围", - question: "请选择范围", - options: [{ label: "城区", description: "中心城区" }], - }, - ], - createdAt: 1003, - } satisfies StreamEvent); - - signal?.addEventListener("abort", () => { - reject(new Error("aborted")); - }); - }), - ); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - act(() => { - void result.current.sendPrompt("测试中断"); - }); - - await waitFor(() => expect(result.current.isStreaming).toBe(true)); - - act(() => { - result.current.abort(); - }); - - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - expect(result.current.messages.at(-1)).toEqual( - expect.objectContaining({ - role: "assistant", - content: "⚠️ **请求已中断**", - isError: true, - progress: [ - expect.objectContaining({ - id: "request-received", - status: "completed", - durationMs: expect.any(Number), - endedAt: expect.any(Number), - }), - ], - todos: expect.objectContaining({ - todos: [ - expect.objectContaining({ - id: "todo-1", - status: "cancelled", - updatedAt: expect.any(Number), - }), - expect.objectContaining({ - id: "todo-2", - status: "cancelled", - updatedAt: expect.any(Number), - }), - ], - }), - permissions: [ - expect.objectContaining({ - requestId: "perm-abort", - status: "rejected", - repliedAt: expect.any(Number), - error: undefined, - }), - ], - questions: [ - expect.objectContaining({ - requestId: "question-abort", - status: "rejected", - repliedAt: expect.any(Number), - error: undefined, - }), - ], - }), - ); - expect(abortAgentChat).toHaveBeenCalledWith("session-1"); - }); - - it("ignores generated session titles after the title was edited manually", async () => { - listChatSessions.mockResolvedValue([]); - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - onEvent({ - type: "session_title", - sessionId: "session-1", - title: "自动标题", - }); - onEvent({ - type: "done", - sessionId: "session-1", - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.switchSession("session-loaded"); - }); - - await act(async () => { - await result.current.renameSession("session-loaded", "手动标题"); - }); - - await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled()); - - await act(async () => { - await result.current.sendPrompt("帮我分析一下"); - }); - - expect(result.current.sessionTitle).toBe("手动标题"); - expect(updateChatSessionTitle).not.toHaveBeenCalledWith( - "session-loaded", - "自动标题", - expect.anything(), - ); - }); - - it("does not apply a late generated title to a newly created session", async () => { - listChatSessions.mockResolvedValue([]); - let emitStreamEvent: ((event: StreamEvent) => void) | undefined; - let resolveStream: (() => void) | undefined; - jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { - emitStreamEvent = onEvent; - await new Promise<void>((resolve) => { - resolveStream = resolve; - }); - }); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - void result.current.sendPrompt("帮我分析一下"); - await Promise.resolve(); - }); - - act(() => { - emitStreamEvent?.({ - type: "done", - sessionId: "old-session", - }); - }); - - await waitFor(() => expect(result.current.isStreaming).toBe(false)); - - act(() => { - result.current.createSession(); - }); - - expect(result.current.sessionTitle).toBe("新对话"); - - await act(async () => { - emitStreamEvent?.({ - type: "session_title", - sessionId: "old-session", - title: "旧请求标题", - }); - resolveStream?.(); - await Promise.resolve(); - }); - - expect(result.current.sessionTitle).toBe("新对话"); - expect(updateChatSessionTitle).toHaveBeenCalledWith( - "old-session", - "旧请求标题", - { isTitleManuallyEdited: false }, - ); - }); - - it("asks the backend to undo the previous user turn before regenerating", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("重新分析压力异常"); - }); - const assistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.regenerate(assistantMessageId); - }); - - expect(streamAgentChat).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - message: "重新分析压力异常", - regenerateFromMessageIndex: 0, - }), - ); - }); - - it("replaces the current chain when regenerating a middle assistant message", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("第一轮"); - }); - - await act(async () => { - await result.current.sendPrompt("第二轮"); - }); - - const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.regenerate(firstAssistantMessageId); - }); - - expect(result.current.messages).toHaveLength(2); - expect(result.current.messages[0]).toEqual( - expect.objectContaining({ - role: "user", - content: "第一轮", - }), - ); - expect(result.current.messages[1]).toEqual( - expect.objectContaining({ - role: "assistant", - content: "", - }), - ); - expect(streamAgentChat).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - message: "第一轮", - regenerateFromMessageIndex: 0, - }), - ); - }); - - it("forks a copied conversation from an assistant message", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("第一轮"); - }); - - const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.createBranch(firstAssistantMessageId); - }); - - expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2); - expect(result.current.activeSessionId).toBe("forked-session"); - expect(result.current.messages).toHaveLength(2); - expect(result.current.messages[0]).toEqual( - expect.objectContaining({ - role: "user", - content: "第一轮", - }), - ); - expect(result.current.messages[1]).toEqual( - expect.objectContaining({ - role: "assistant", - }), - ); - expect(streamAgentChat).toHaveBeenCalledTimes(1); - }); -}); +// Tests for useAgentChatSession are split by behavior boundary. +// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx. diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index bb1ace1..b5f70c3 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -2,509 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { - abortAgentChat, - forkAgentChat, - rejectAgentQuestion, - replyAgentPermission, - replyAgentQuestion, - resumeAgentChatStream, - streamAgentChat, -} from "@/lib/chatStream"; -import type { - AgentApprovalMode, - AgentModel, - AgentQuestionRequest, - AgentTodoUpdate, - PermissionReply, - StreamEvent, -} from "@/lib/chatStream"; -import type { - AgentArtifact, - AgentPermissionRequest, - ChatProgress, - ChatSessionSummary, - LoadedChatState, - Message, -} from "../GlobalChatbox.types"; -import { - cloneMessages, - createId, -} from "../GlobalChatbox.utils"; -import { - createEmptyChatState, - deleteChatSession, - listChatSessions, - loadChatSessionById, - saveActiveChatState, - updateChatSessionTitle, -} from "../chatStorage"; - -type UseAgentChatSessionOptions = { - projectId?: string | null; - onToolCall: ( - event: StreamEvent & { type: "tool_call" }, - options: { - assistantMessageId: string; - appendArtifact: (messageId: string, artifact: AgentArtifact) => void; - }, - ) => void; - onBeforeSend?: () => void; - getModel?: () => AgentModel; - getApprovalMode?: () => AgentApprovalMode; -}; - -type PromptRunOptions = { - prompt: string; - sessionIdOverride?: string; - regenerateFromMessageIndex?: number; - preparedMessages?: Message[]; - userMessage?: Message; - assistantMessage?: Message; -}; - -const createPersistedStateKey = (state: LoadedChatState) => - JSON.stringify({ - title: state.title ?? null, - isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, - sessionId: state.sessionId ?? null, - messages: state.messages, - }); - -const upsertProgress = ( - progress: ChatProgress[] | undefined, - event: StreamEvent & { type: "progress" }, -) => { - const next = [...(progress ?? [])]; - const index = next.findIndex((item) => item.id === event.id); - const existing = index >= 0 ? next[index] : undefined; - const now = Date.now(); - const startedAt = event.startedAt ?? existing?.startedAt; - const isRunning = event.status === "running"; - const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now; - const elapsedMs = isRunning - ? event.elapsedMs ?? - existing?.elapsedMs ?? - (startedAt !== undefined ? Math.max(0, now - startedAt) : undefined) - : undefined; - const elapsedSnapshotAt = isRunning - ? event.elapsedMs !== undefined - ? now - : existing?.elapsedSnapshotAt ?? now - : undefined; - const durationMs = !isRunning - ? event.durationMs ?? - existing?.durationMs ?? - (startedAt !== undefined && endedAt !== undefined - ? Math.max(0, endedAt - startedAt) - : undefined) - : undefined; - const nextItem: ChatProgress = { - id: event.id, - phase: event.phase, - status: event.status, - title: event.title, - detail: event.detail, - startedAt, - endedAt, - elapsedMs, - elapsedSnapshotAt, - durationMs, - }; - if (index >= 0) { - next[index] = nextItem; - } else { - next.push(nextItem); - } - return next; -}; - -const completeRunningProgress = (progress: ChatProgress[] | undefined) => - progress?.map((item) => { - if (item.status !== "running") { - return item; - } - const endedAt = Date.now(); - return { - ...item, - status: "completed" as const, - endedAt, - elapsedMs: undefined, - elapsedSnapshotAt: undefined, - durationMs: - item.durationMs ?? - (item.startedAt !== undefined - ? Math.max(0, endedAt - item.startedAt) - : item.elapsedMs), - }; - }); - -const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => - todoUpdate - ? { - ...todoUpdate, - todos: todoUpdate.todos.map((todo) => - todo.status === "pending" || todo.status === "in_progress" - ? { - ...todo, - status: "cancelled" as const, - updatedAt: Date.now(), - } - : todo, - ), - } - : undefined; - -const upsertPermission = ( - permissions: AgentPermissionRequest[] | undefined, - event: StreamEvent & { type: "permission_request" }, -) => { - const next = [...(permissions ?? [])]; - const index = next.findIndex((item) => item.requestId === event.requestId); - const nextItem: AgentPermissionRequest = { - requestId: event.requestId, - sessionId: event.sessionId, - permission: event.permission, - patterns: event.patterns, - metadata: event.metadata, - always: event.always, - tool: event.tool, - createdAt: event.createdAt, - status: "pending", - }; - if (index >= 0) { - next[index] = { - ...next[index], - ...nextItem, - status: next[index].status === "submitting" ? "submitting" : nextItem.status, - }; - } else { - next.push(nextItem); - } - return next; -}; - -const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => { - if (reply === "always") return "approved_always"; - if (reply === "once") return "approved_once"; - return "rejected"; -}; - -const isActionableQuestionRequest = (question: { - requestId: string; - tool?: AgentQuestionRequest["tool"]; -}) => Boolean(question.requestId && question.requestId !== question.tool?.callID); - -const toQuestionRequest = ( - event: StreamEvent & { type: "question_request" }, - status: AgentQuestionRequest["status"] = "pending", -): AgentQuestionRequest => ({ - requestId: event.requestId, - sessionId: event.sessionId, - questions: event.questions, - tool: event.tool, - createdAt: event.createdAt, - status, -}); - -const getQuestionContentSignature = ( - questions: AgentQuestionRequest["questions"], -) => - JSON.stringify( - questions.map((question) => ({ - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - multiple: question.multiple ?? false, - custom: question.custom !== false, - })), - ); - -const isSameQuestionRequest = ( - question: AgentQuestionRequest, - event: StreamEvent & { type: "question_request" }, -) => { - if (question.requestId === event.requestId) return true; - if (question.tool?.callID && event.tool?.callID) { - return question.tool.callID === event.tool.callID; - } - return ( - question.status === "pending" && - question.sessionId === event.sessionId && - getQuestionContentSignature(question.questions) === - getQuestionContentSignature(event.questions) - ); -}; - -const isSameQuestionPair = ( - left: AgentQuestionRequest, - right: AgentQuestionRequest, -) => { - if (left.requestId === right.requestId) return true; - if (left.tool?.callID && right.tool?.callID) { - return left.tool.callID === right.tool.callID; - } - return ( - left.status === "pending" && - right.status === "pending" && - left.sessionId === right.sessionId && - getQuestionContentSignature(left.questions) === - getQuestionContentSignature(right.questions) - ); -}; - -const dedupeQuestionsAcrossMessages = (messages: Message[]) => { - const seen: AgentQuestionRequest[] = []; - let changed = false; - const nextMessages = messages.map((message) => { - if (!message.questions?.length) { - return message; - } - const nextQuestions = message.questions.filter((question) => { - if (seen.some((existing) => isSameQuestionPair(existing, question))) { - changed = true; - return false; - } - seen.push(question); - return true; - }); - if (nextQuestions.length === message.questions.length) { - return message; - } - return { - ...message, - questions: nextQuestions.length ? nextQuestions : undefined, - }; - }); - return changed ? nextMessages : messages; -}; - -const upsertQuestionAcrossMessages = ( - messages: Message[], - event: StreamEvent & { type: "question_request" }, - assistantMessageId: string, -) => { - let existing: AgentQuestionRequest | undefined; - for (const message of messages) { - const match = message.questions?.find((question) => - isSameQuestionRequest(question, event), - ); - if (match) { - existing = match; - break; - } - } - - const existingStatus: AgentQuestionRequest["status"] | undefined = - existing?.status === "submitting" ? "submitting" : undefined; - const nextQuestion = - existing && - isActionableQuestionRequest(existing) && - !isActionableQuestionRequest(event) - ? { - ...existing, - sessionId: event.sessionId, - questions: event.questions, - tool: event.tool ?? existing.tool, - createdAt: event.createdAt, - status: existingStatus ?? existing.status, - } - : toQuestionRequest(event, existingStatus ?? "pending"); - const targetMessageId = existing - ? messages.find((message) => - message.questions?.some((question) => isSameQuestionRequest(question, event)), - )?.id ?? assistantMessageId - : assistantMessageId; - - return messages.map((message) => { - const filteredQuestions = message.questions?.filter( - (question) => !isSameQuestionRequest(question, event), - ); - if (message.id !== targetMessageId) { - return filteredQuestions?.length === message.questions?.length - ? message - : { - ...message, - questions: filteredQuestions?.length ? filteredQuestions : undefined, - }; - } - - const nextQuestions = [...(filteredQuestions ?? []), nextQuestion]; - return { - ...message, - questions: nextQuestions, - }; - }); -}; - -const applyQuestionResponse = ( - questions: AgentQuestionRequest[] | undefined, - event: StreamEvent & { type: "question_response" }, -) => - (questions ?? []).map((question) => - question.requestId === event.requestId - ? { - ...question, - status: event.rejected ? "rejected" as const : "answered" as const, - answers: event.answers ?? question.answers, - repliedAt: Date.now(), - error: undefined, - } - : question, - ); - -const createTodoUpdateFromEvent = ( - event: StreamEvent & { type: "todo_update" }, -): AgentTodoUpdate => ({ - sessionId: event.sessionId, - messageId: event.messageId, - todos: event.todos, - createdAt: event.createdAt, -}); - -const normalizeSessionTodos = ( - messages: Message[], - nextTodoUpdate?: AgentTodoUpdate, - targetAssistantMessageId?: string, -) => { - let latestTodoUpdate = nextTodoUpdate; - if (!latestTodoUpdate) { - for (const message of messages) { - if (message.todos) { - latestTodoUpdate = message.todos; - } - } - } - - if (!latestTodoUpdate) { - return messages; - } - - const targetMessageId = - targetAssistantMessageId ?? - [...messages].reverse().find((message) => message.role === "assistant")?.id; - if (!targetMessageId) { - return messages; - } - - let changed = false; - const nextMessages = messages.map((message) => { - if (message.id === targetMessageId) { - if (message.todos === latestTodoUpdate) { - return message; - } - changed = true; - return { - ...message, - todos: latestTodoUpdate, - }; - } - if (!message.todos) { - return message; - } - changed = true; - return { - ...message, - todos: undefined, - }; - }); - - return changed ? nextMessages : messages; -}; - -const rejectOpenPermissionsAfterAbort = ( - permissions: AgentPermissionRequest[] | undefined, -) => { - if (!permissions?.length) return permissions; - let changed = false; - const nextPermissions = permissions.map((permission) => { - if ( - permission.status !== "pending" && - permission.status !== "submitting" && - permission.status !== "error" - ) { - return permission; - } - changed = true; - return { - ...permission, - status: "rejected" as const, - repliedAt: Date.now(), - error: undefined, - }; - }); - return changed ? nextPermissions : permissions; -}; - -const rejectOpenQuestionsAfterAbort = ( - questions: AgentQuestionRequest[] | undefined, -) => { - if (!questions?.length) return questions; - let changed = false; - const nextQuestions = questions.map((question) => { - if ( - question.status !== "pending" && - question.status !== "submitting" && - question.status !== "error" - ) { - return question; - } - changed = true; - return { - ...question, - status: "rejected" as const, - repliedAt: Date.now(), - error: undefined, - }; - }); - return changed ? nextQuestions : questions; -}; - -const finalizeAssistantMessageAfterAbort = (message: Message): Message => { - const completedProgress = completeRunningProgress(message.progress); - const cancelledTodos = cancelRunningTodos(message.todos); - const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions); - const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); - const hasVisibleOutput = - message.content.trim().length > 0 || - Boolean(message.artifacts?.length) || - Boolean(rejectedPermissions?.length) || - Boolean(rejectedQuestions?.length) || - Boolean(completedProgress?.length) || - Boolean(cancelledTodos); - - if (!hasVisibleOutput) { - return message; - } - - return { - ...message, - content: message.content || "⚠️ **请求已中断**", - isError: true, - progress: completedProgress, - permissions: rejectedPermissions, - questions: rejectedQuestions, - todos: cancelledTodos, - }; -}; - -const createUserMessage = (content: string): Message => { - const id = createId(); - return { - id, - role: "user", - content, - }; -}; - -const createAssistantMessage = (): Message => ({ - id: createId(), - role: "assistant", - content: "", -}); +import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; +import type { PermissionReply, StreamEvent } from "@/lib/chatStream"; +import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types"; +import { cloneMessages } from "../GlobalChatbox.utils"; +import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage"; +import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; +import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; export const useAgentChatSession = ({ projectId, diff --git a/src/components/chat/hooks/useAgentChatSession.types.ts b/src/components/chat/hooks/useAgentChatSession.types.ts new file mode 100644 index 0000000..38e87d2 --- /dev/null +++ b/src/components/chat/hooks/useAgentChatSession.types.ts @@ -0,0 +1,25 @@ +import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream"; +import type { AgentArtifact, Message } from "../GlobalChatbox.types"; + +export type UseAgentChatSessionOptions = { + projectId?: string | null; + onToolCall: ( + event: StreamEvent & { type: "tool_call" }, + options: { + assistantMessageId: string; + appendArtifact: (messageId: string, artifact: AgentArtifact) => void; + }, + ) => void; + onBeforeSend?: () => void; + getModel?: () => AgentModel; + getApprovalMode?: () => AgentApprovalMode; +}; + +export type PromptRunOptions = { + prompt: string; + sessionIdOverride?: string; + regenerateFromMessageIndex?: number; + preparedMessages?: Message[]; + userMessage?: Message; + assistantMessage?: Message; +}; -- 2.54.0 From e5f13c3d46d6e1f91abf05972b85d836ff8a95ce Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 19:33:06 +0800 Subject: [PATCH 172/281] fix(chat): remove regenerate action --- src/components/chat/AgentTurn.tsx | 15 ---- src/components/chat/AgentWorkspace.test.tsx | 1 - src/components/chat/AgentWorkspace.tsx | 8 -- src/components/chat/GlobalChatbox.tsx | 2 - .../useAgentChatSession.actions.test.tsx | 78 ------------------- .../chat/hooks/useAgentChatSession.ts | 40 ---------- .../chat/hooks/useAgentChatSession.types.ts | 1 - src/lib/chatStream.test.ts | 1 - src/lib/chatStream.ts | 3 - 9 files changed, 149 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 4acf1e9..5a5c794 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -15,7 +15,6 @@ import { useTheme, } from "@mui/material"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; -import RefreshRounded from "@mui/icons-material/RefreshRounded"; import { TbArrowsSplit2 } from "react-icons/tb"; import type { PermissionReply } from "@/lib/chatStream"; import { @@ -46,7 +45,6 @@ type AgentTurnProps = { onResume: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: (messageId: string) => void; onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void; @@ -62,7 +60,6 @@ export const AgentTurn = React.memo( onResume, onStopSpeech, isTtsSupported, - onRegenerate, onCreateBranch, onReplyPermission, onReplyQuestion, @@ -316,18 +313,6 @@ export const AgentTurn = React.memo( <ContentCopyRounded sx={{ fontSize: 16 }} /> </IconButton> </Tooltip> - <Tooltip title="重新生成"> - <IconButton - size="small" - aria-label="重新生成" - onClick={() => { - onRegenerate(message.id); - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - <RefreshRounded sx={{ fontSize: 16 }} /> - </IconButton> - </Tooltip> <Tooltip title="拆分为新会话"> <IconButton size="small" diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index ac62e21..d63f033 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -41,7 +41,6 @@ describe("AgentWorkspace", () => { onResumeSpeech: jest.fn(), onStopSpeech: jest.fn(), isTtsSupported: false, - onRegenerate: jest.fn(), onCreateBranch: jest.fn(), onReplyPermission: jest.fn(), onReplyQuestion: jest.fn(), diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index dc76f07..6add4ec 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -28,7 +28,6 @@ type AgentWorkspaceProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: (messageId: string) => void; onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void; @@ -44,7 +43,6 @@ type TurnListProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: (messageId: string) => void; onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void; @@ -64,7 +62,6 @@ const TurnListInner = ({ onResumeSpeech, onStopSpeech, isTtsSupported, - onRegenerate, onCreateBranch, onReplyPermission, onReplyQuestion, @@ -82,7 +79,6 @@ const TurnListInner = ({ onResume={onResumeSpeech} onStopSpeech={onStopSpeech} isTtsSupported={isTtsSupported} - onRegenerate={onRegenerate} onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} onReplyQuestion={onReplyQuestion} @@ -104,7 +100,6 @@ const TurnList = React.memo( prevProps.onResumeSpeech === nextProps.onResumeSpeech && prevProps.onStopSpeech === nextProps.onStopSpeech && prevProps.isTtsSupported === nextProps.isTtsSupported && - prevProps.onRegenerate === nextProps.onRegenerate && prevProps.onCreateBranch === nextProps.onCreateBranch && prevProps.onReplyPermission === nextProps.onReplyPermission && prevProps.onReplyQuestion === nextProps.onReplyQuestion && @@ -238,7 +233,6 @@ export const AgentWorkspace = ({ onResumeSpeech, onStopSpeech, isTtsSupported, - onRegenerate, onCreateBranch, onReplyPermission, onReplyQuestion, @@ -287,7 +281,6 @@ export const AgentWorkspace = ({ onResumeSpeech={onResumeSpeech} onStopSpeech={onStopSpeech} isTtsSupported={isTtsSupported} - onRegenerate={onRegenerate} onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} onReplyQuestion={onReplyQuestion} @@ -304,7 +297,6 @@ export const AgentWorkspace = ({ onResumeSpeech={onResumeSpeech} onStopSpeech={onStopSpeech} isTtsSupported={isTtsSupported} - onRegenerate={onRegenerate} onCreateBranch={onCreateBranch} onReplyPermission={onReplyPermission} onReplyQuestion={onReplyQuestion} diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index b73f62f..7988feb 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -71,7 +71,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { isStreaming, sessionTitle, sendPrompt, - regenerate, createBranch, abort, replyPermission, @@ -352,7 +351,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onResumeSpeech={handleResumeSpeech} onStopSpeech={handleStopSpeech} isTtsSupported={isTtsSupported} - onRegenerate={regenerate} onCreateBranch={createBranch} onReplyPermission={replyPermission} onReplyQuestion={replyQuestion} diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx index 3403ba7..e900682 100644 --- a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -359,84 +359,6 @@ describe("useAgentChatSession actions", () => { ); }); - it("asks the backend to undo the previous user turn before regenerating", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("重新分析压力异常"); - }); - const assistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.regenerate(assistantMessageId); - }); - - expect(streamAgentChat).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - message: "重新分析压力异常", - regenerateFromMessageIndex: 0, - }), - ); - }); - - it("replaces the current chain when regenerating a middle assistant message", async () => { - listChatSessions.mockResolvedValue([]); - - const { result } = renderHook(() => - useAgentChatSession({ - projectId: "project-1", - onToolCall: jest.fn(), - }), - ); - - await waitFor(() => expect(result.current.isHydrating).toBe(false)); - - await act(async () => { - await result.current.sendPrompt("第一轮"); - }); - - await act(async () => { - await result.current.sendPrompt("第二轮"); - }); - - const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; - - await act(async () => { - await result.current.regenerate(firstAssistantMessageId); - }); - - expect(result.current.messages).toHaveLength(2); - expect(result.current.messages[0]).toEqual( - expect.objectContaining({ - role: "user", - content: "第一轮", - }), - ); - expect(result.current.messages[1]).toEqual( - expect.objectContaining({ - role: "assistant", - content: "", - }), - ); - expect(streamAgentChat).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - message: "第一轮", - regenerateFromMessageIndex: 0, - }), - ); - }); - it("forks a copied conversation from an assistant message", async () => { listChatSessions.mockResolvedValue([]); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index b5f70c3..04cde02 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -407,7 +407,6 @@ export const useAgentChatSession = ({ async ({ prompt: rawPrompt, sessionIdOverride, - regenerateFromMessageIndex, preparedMessages, userMessage, assistantMessage, @@ -442,7 +441,6 @@ export const useAgentChatSession = ({ sessionId: sessionIdOverride ?? sessionIdRef.current, model: getModel?.(), approvalMode: getApprovalMode?.(), - regenerateFromMessageIndex, signal: controller.signal, onEvent: (event) => applyStreamEvent(event, { @@ -893,43 +891,6 @@ export const useAgentChatSession = ({ [isHydrating, messages], ); - const regenerate = useCallback(async (messageId: string) => { - if (isHydrating || isStreaming || messages.length === 0) return; - - const targetAssistantIndex = messages.findIndex( - (message) => message.id === messageId && message.role === "assistant", - ); - if (targetAssistantIndex < 0) { - return; - } - - let targetUserIndex = targetAssistantIndex - 1; - while (targetUserIndex >= 0 && messages[targetUserIndex].role !== "user") { - targetUserIndex--; - } - - if (targetUserIndex < 0) return; - - const targetUser = messages[targetUserIndex]; - const targetUserContent = targetUser.content; - const nextMessages = cloneMessages(messages.slice(0, targetUserIndex)); - const nextUserMessage = createUserMessage(targetUserContent); - const nextAssistantMessage = createAssistantMessage(); - - setMessages(nextMessages); - await runPrompt({ - prompt: targetUserContent, - regenerateFromMessageIndex: targetUserIndex, - preparedMessages: [ - ...nextMessages, - nextUserMessage, - nextAssistantMessage, - ], - userMessage: nextUserMessage, - assistantMessage: nextAssistantMessage, - }); - }, [isHydrating, isStreaming, messages, runPrompt]); - const createBranch = useCallback( async (messageId: string) => { if (isHydrating || isStreaming) return; @@ -975,7 +936,6 @@ export const useAgentChatSession = ({ sessionTitle, sessionId, sendPrompt, - regenerate, createBranch, abort, replyPermission, diff --git a/src/components/chat/hooks/useAgentChatSession.types.ts b/src/components/chat/hooks/useAgentChatSession.types.ts index 38e87d2..5478f7d 100644 --- a/src/components/chat/hooks/useAgentChatSession.types.ts +++ b/src/components/chat/hooks/useAgentChatSession.types.ts @@ -18,7 +18,6 @@ export type UseAgentChatSessionOptions = { export type PromptRunOptions = { prompt: string; sessionIdOverride?: string; - regenerateFromMessageIndex?: number; preparedMessages?: Message[]; userMessage?: Message; assistantMessage?: Message; diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index fc71e5c..77c4593 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -75,7 +75,6 @@ describe("streamAgentChat", () => { session_id: undefined, model: "deepseek/deepseek-v4-pro", approval_mode: undefined, - regenerate_from_message_index: undefined, }), }), ); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index b5c1e37..84886a7 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -140,7 +140,6 @@ type StreamOptions = { sessionId?: string; model?: AgentModel; approvalMode?: AgentApprovalMode; - regenerateFromMessageIndex?: number; signal?: AbortSignal; onEvent: (event: StreamEvent) => void; }; @@ -460,7 +459,6 @@ export const streamAgentChat = async ({ sessionId, model, approvalMode, - regenerateFromMessageIndex, signal, onEvent, }: StreamOptions) => { @@ -480,7 +478,6 @@ export const streamAgentChat = async ({ session_id: sessionId, model, approval_mode: approvalMode, - regenerate_from_message_index: regenerateFromMessageIndex, }), projectHeaderMode: "include", userHeaderMode: "include", -- 2.54.0 From 166b45e52944c6e22b83ee763ecd03e9f5006d61 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 19:47:13 +0800 Subject: [PATCH 173/281] fix(chat): normalize loaded messages --- .../chat/GlobalChatbox.utils.test.ts | 35 ++++++++++ src/components/chat/GlobalChatbox.utils.ts | 64 ++++++++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/components/chat/GlobalChatbox.utils.test.ts diff --git a/src/components/chat/GlobalChatbox.utils.test.ts b/src/components/chat/GlobalChatbox.utils.test.ts new file mode 100644 index 0000000..8e89124 --- /dev/null +++ b/src/components/chat/GlobalChatbox.utils.test.ts @@ -0,0 +1,35 @@ +import { cloneMessage } from "./GlobalChatbox.utils"; +import type { Message } from "./GlobalChatbox.types"; + +describe("cloneMessage", () => { + it("normalizes persisted question and todo arrays", () => { + const message = { + id: "assistant-1", + role: "assistant", + content: "需要补充信息", + questions: [ + { + requestId: "question-1", + sessionId: "session-1", + questions: [ + { + header: "范围", + question: "请选择分析范围", + }, + ], + createdAt: 1, + status: "pending", + }, + ], + todos: { + sessionId: "session-1", + createdAt: 1, + }, + } as unknown as Message; + + const cloned = cloneMessage(message); + + expect(cloned.questions?.[0]?.questions[0]?.options).toEqual([]); + expect(cloned.todos?.todos).toEqual([]); + }); +}); diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index 4564a52..44ad9b6 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -1,4 +1,8 @@ import type { Message } from "./GlobalChatbox.types"; +import type { + AgentQuestionRequest, + AgentTodoUpdate, +} from "@/lib/chatStream"; export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -29,10 +33,66 @@ export const stripMarkdown = (md: string): string => .replace(/<[^>]+>/g, "") .trim(); +const normalizeQuestionRequests = ( + questions: Message["questions"], +): Message["questions"] => + Array.isArray(questions) + ? questions.map((request) => ({ + ...request, + questions: Array.isArray(request.questions) + ? request.questions.map((question) => ({ + ...question, + header: typeof question.header === "string" ? question.header : "", + question: + typeof question.question === "string" ? question.question : "", + options: Array.isArray(question.options) + ? question.options.map((option) => ({ + label: + typeof option.label === "string" ? option.label : "", + description: + typeof option.description === "string" + ? option.description + : "", + })) + : [], + })) + : [], + answers: Array.isArray(request.answers) + ? request.answers.map((answer) => + Array.isArray(answer) + ? answer.filter((item): item is string => typeof item === "string") + : [], + ) + : undefined, + } satisfies AgentQuestionRequest)) + : undefined; + +const normalizeTodoUpdate = (todos: Message["todos"]): Message["todos"] => { + if (!todos) return undefined; + return { + ...todos, + todos: Array.isArray(todos.todos) + ? todos.todos.map((todo) => ({ ...todo })) + : [], + } satisfies AgentTodoUpdate; +}; + export const cloneMessage = (message: Message): Message => ({ ...message, - progress: message.progress ? [...message.progress] : undefined, - artifacts: message.artifacts ? [...message.artifacts] : undefined, + progress: Array.isArray(message.progress) ? [...message.progress] : undefined, + artifacts: Array.isArray(message.artifacts) ? [...message.artifacts] : undefined, + permissions: Array.isArray(message.permissions) + ? message.permissions.map((permission) => ({ + ...permission, + patterns: Array.isArray(permission.patterns) + ? [...permission.patterns] + : [], + always: Array.isArray(permission.always) ? [...permission.always] : [], + metadata: permission.metadata ?? {}, + })) + : undefined, + questions: normalizeQuestionRequests(message.questions), + todos: normalizeTodoUpdate(message.todos), }); export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage); -- 2.54.0 From 7da0ed0e3913b4f9d03fc4f5798ace66e0f6a93c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 19:54:25 +0800 Subject: [PATCH 174/281] fix(chat): mark aborted permissions --- .../chat/AgentPermissionRequests.tsx | 41 +++++++++++++++---- src/components/chat/GlobalChatbox.types.ts | 1 + .../chat/hooks/agentChatSessionState.ts | 11 +++-- .../useAgentChatSession.actions.test.tsx | 2 +- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/components/chat/AgentPermissionRequests.tsx b/src/components/chat/AgentPermissionRequests.tsx index cf67a8e..4409935 100644 --- a/src/components/chat/AgentPermissionRequests.tsx +++ b/src/components/chat/AgentPermissionRequests.tsx @@ -94,6 +94,7 @@ const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[nu if (status === "approved_always") return "已始终允许"; if (status === "approved_once") return "已允许一次"; if (status === "rejected") return "已拒绝"; + if (status === "aborted") return "已中断"; if (status === "error") return "提交失败"; if (status === "submitting") return "提交中"; return "等待确认"; @@ -109,6 +110,7 @@ const getPermissionStatusColor = ( if (status === "approved_once") return approvedOncePermissionColor; if (status === "approved_always") return theme.palette.success.main; if (status === "rejected" || status === "error") return theme.palette.error.main; + if (status === "aborted") return theme.palette.text.secondary; return pendingPermissionColor; }; @@ -119,19 +121,23 @@ const getPermissionStatusTextColor = ( if (status === "approved_once") return "#006c78"; if (status === "approved_always") return theme.palette.success.dark; if (status === "rejected" || status === "error") return theme.palette.error.main; + if (status === "aborted") return theme.palette.text.secondary; return "#8a5a00"; }; const PermissionRequestCard = ({ permission, + isRunning, onReply, }: { permission: NonNullable<Message["permissions"]>[number]; + isRunning: boolean; onReply: (requestId: string, reply: PermissionReply) => void; }) => { const theme = useTheme(); - const isPending = permission.status === "pending" || permission.status === "error"; - const isSubmitting = permission.status === "submitting"; + const isPending = + isRunning && (permission.status === "pending" || permission.status === "error"); + const isSubmitting = isRunning && permission.status === "submitting"; const primaryValue = getPermissionPrimaryValue(permission); const metadataText = formatMetadata(permission.metadata); const accentColor = getPermissionStatusColor(permission.status, theme); @@ -363,7 +369,13 @@ export const PermissionRequestGroup = ({ const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length; const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length; - const pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount; + const abortedCount = permissions.filter((permission) => permission.status === "aborted").length; + const pendingCount = permissions.filter( + (permission) => + permission.status === "pending" || + permission.status === "submitting" || + permission.status === "error", + ).length; const hasPendingPermissions = pendingCount > 0; const [expanded, setExpanded] = React.useState(false); const latestPermissions = permissions.slice(-3); @@ -378,9 +390,24 @@ export const PermissionRequestGroup = ({ { label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, { label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, { label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) }, + { label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) }, ]; - const chipColor = pendingCount > 0 ? getPermissionStatusColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusColor("rejected", theme) : getPermissionStatusColor("approved_always", theme); - const chipTextColor = pendingCount > 0 ? getPermissionStatusTextColor("pending", theme) : rejectedCount > 0 ? getPermissionStatusTextColor("rejected", theme) : getPermissionStatusTextColor("approved_always", theme); + const chipColor = + pendingCount > 0 + ? getPermissionStatusColor("pending", theme) + : abortedCount > 0 + ? getPermissionStatusColor("aborted", theme) + : rejectedCount > 0 + ? getPermissionStatusColor("rejected", theme) + : getPermissionStatusColor("approved_always", theme); + const chipTextColor = + pendingCount > 0 + ? getPermissionStatusTextColor("pending", theme) + : abortedCount > 0 + ? getPermissionStatusTextColor("aborted", theme) + : rejectedCount > 0 + ? getPermissionStatusTextColor("rejected", theme) + : getPermissionStatusTextColor("approved_always", theme); return ( <Box @@ -591,6 +618,7 @@ export const PermissionRequestGroup = ({ <PermissionRequestCard key={permission.requestId} permission={permission} + isRunning={isRunning} onReply={onReply} /> ))} @@ -605,6 +633,7 @@ export const PermissionRequestGroup = ({ <PermissionRequestCard key={permission.requestId} permission={permission} + isRunning={isRunning} onReply={onReply} /> ))} @@ -613,5 +642,3 @@ export const PermissionRequestGroup = ({ </Box> ); }; - - diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index c74c2b5..8877e6b 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -33,6 +33,7 @@ export type AgentPermissionStatus = | "approved_once" | "approved_always" | "rejected" + | "aborted" | "error"; export type AgentPermissionRequest = { diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts index 56d1699..203bb62 100644 --- a/src/components/chat/hooks/agentChatSessionState.ts +++ b/src/components/chat/hooks/agentChatSessionState.ts @@ -364,7 +364,7 @@ export const normalizeSessionTodos = ( return changed ? nextMessages : messages; }; -export const rejectOpenPermissionsAfterAbort = ( +export const abortOpenPermissionsAfterAbort = ( permissions: AgentPermissionRequest[] | undefined, ) => { if (!permissions?.length) return permissions; @@ -380,7 +380,7 @@ export const rejectOpenPermissionsAfterAbort = ( changed = true; return { ...permission, - status: "rejected" as const, + status: "aborted" as const, repliedAt: Date.now(), error: undefined, }; @@ -415,12 +415,12 @@ export const rejectOpenQuestionsAfterAbort = ( export const finalizeAssistantMessageAfterAbort = (message: Message): Message => { const completedProgress = completeRunningProgress(message.progress); const cancelledTodos = cancelRunningTodos(message.todos); - const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions); + const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions); const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); const hasVisibleOutput = message.content.trim().length > 0 || Boolean(message.artifacts?.length) || - Boolean(rejectedPermissions?.length) || + Boolean(abortedPermissions?.length) || Boolean(rejectedQuestions?.length) || Boolean(completedProgress?.length) || Boolean(cancelledTodos); @@ -434,7 +434,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message => content: message.content || "⚠️ **请求已中断**", isError: true, progress: completedProgress, - permissions: rejectedPermissions, + permissions: abortedPermissions, questions: rejectedQuestions, todos: cancelledTodos, }; @@ -454,4 +454,3 @@ export const createAssistantMessage = (): Message => ({ role: "assistant", content: "", }); - diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx index e900682..8149ea2 100644 --- a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -238,7 +238,7 @@ describe("useAgentChatSession actions", () => { permissions: [ expect.objectContaining({ requestId: "perm-abort", - status: "rejected", + status: "aborted", repliedAt: expect.any(Number), error: undefined, }), -- 2.54.0 From 968d798a2a64ce32d409782eb3a46a27dc5d7bf6 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 20:12:08 +0800 Subject: [PATCH 175/281] fix(chat): hide raw permission metadata --- .../chat/AgentPermissionRequests.tsx | 44 +------------------ src/components/chat/GlobalChatbox.types.ts | 2 +- src/components/chat/GlobalChatbox.utils.ts | 1 - .../chat/hooks/agentChatSessionState.ts | 2 +- .../useAgentChatSession.actions.test.tsx | 4 +- src/lib/chatStream.test.ts | 4 +- src/lib/chatStream.ts | 6 +-- 7 files changed, 11 insertions(+), 52 deletions(-) diff --git a/src/components/chat/AgentPermissionRequests.tsx b/src/components/chat/AgentPermissionRequests.tsx index 4409935..7c94387 100644 --- a/src/components/chat/AgentPermissionRequests.tsx +++ b/src/components/chat/AgentPermissionRequests.tsx @@ -27,32 +27,6 @@ import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import type { PermissionReply } from "@/lib/chatStream"; import type { Message } from "./GlobalChatbox.types"; -const formatMetadataValue = (value: unknown) => { - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return "[unserializable]"; - } -}; - -const truncateText = (value: string, maxLength: number) => - value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; - -const formatMetadata = (metadata: Record<string, unknown>) => { - const entries = Object.entries(metadata) - .filter(([key]) => !["command", "path", "file", "directory"].includes(key)) - .slice(0, 3); - if (!entries.length) { - return ""; - } - return entries - .map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`) - .join(";"); -}; - const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => { if (permission.permission === "external_directory") return "访问工作区外目录"; if (permission.permission === "bash") return "执行终端命令"; @@ -63,15 +37,8 @@ const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[numb const getPermissionPrimaryValue = ( permission: NonNullable<Message["permissions"]>[number], ) => { - const command = permission.metadata.command; - if (typeof command === "string" && command.trim()) { - return command.trim(); - } - for (const key of ["path", "file", "directory"]) { - const value = permission.metadata[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } + if (typeof permission.target === "string" && permission.target.trim()) { + return permission.target.trim(); } return permission.patterns[0] ?? permission.permission; }; @@ -139,7 +106,6 @@ const PermissionRequestCard = ({ isRunning && (permission.status === "pending" || permission.status === "error"); const isSubmitting = isRunning && permission.status === "submitting"; const primaryValue = getPermissionPrimaryValue(permission); - const metadataText = formatMetadata(permission.metadata); const accentColor = getPermissionStatusColor(permission.status, theme); const statusTextColor = getPermissionStatusTextColor(permission.status, theme); const statusLabel = getPermissionStatusLabel(permission.status); @@ -237,12 +203,6 @@ const PermissionRequestCard = ({ {primaryValue} </Typography> </Box> - - {metadataText ? ( - <Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}> - {metadataText} - </Typography> - ) : null} </Stack> {permission.error ? ( diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 8877e6b..95df637 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -41,7 +41,7 @@ export type AgentPermissionRequest = { sessionId: string; permission: string; patterns: string[]; - metadata: Record<string, unknown>; + target?: string; always: string[]; tool?: { messageID: string; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index 44ad9b6..f343664 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -88,7 +88,6 @@ export const cloneMessage = (message: Message): Message => ({ ? [...permission.patterns] : [], always: Array.isArray(permission.always) ? [...permission.always] : [], - metadata: permission.metadata ?? {}, })) : undefined, questions: normalizeQuestionRequests(message.questions), diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts index 203bb62..2961f9e 100644 --- a/src/components/chat/hooks/agentChatSessionState.ts +++ b/src/components/chat/hooks/agentChatSessionState.ts @@ -115,7 +115,7 @@ export const upsertPermission = ( sessionId: event.sessionId, permission: event.permission, patterns: event.patterns, - metadata: event.metadata, + target: event.target, always: event.always, tool: event.tool, createdAt: event.createdAt, diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx index 8149ea2..5b7e868 100644 --- a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -99,7 +99,7 @@ describe("useAgentChatSession actions", () => { requestId: "perm-1", permission: "bash", patterns: ["rm *"], - metadata: { command: "rm tmp.txt" }, + target: "rm tmp.txt", always: ["rm *"], createdAt: 123, }); @@ -163,7 +163,7 @@ describe("useAgentChatSession actions", () => { requestId: "perm-abort", permission: "bash", patterns: ["npm test"], - metadata: { command: "npm test" }, + target: "npm test", always: ["npm test"], createdAt: 1002, } satisfies StreamEvent); diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 77c4593..339eaa9 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -186,7 +186,7 @@ describe("streamAgentChat", () => { apiFetch.mockResolvedValue({ ok: true, body: makeStream([ - 'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"metadata":{"command":"rm tmp.txt"},"always":["rm *"],"created_at":123}\n\n', + 'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n', 'event: permission_response\ndata: {"session_id":"s1","request_id":"perm-1","reply":"reject"}\n\n', ]), }); @@ -205,7 +205,7 @@ describe("streamAgentChat", () => { requestId: "perm-1", permission: "bash", patterns: ["rm *"], - metadata: { command: "rm tmp.txt" }, + target: "rm tmp.txt", always: ["rm *"], tool: undefined, createdAt: 123, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 84886a7..f1fc494 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -98,7 +98,7 @@ export type StreamEvent = requestId: string; permission: string; patterns: string[]; - metadata: Record<string, unknown>; + target?: string; always: string[]; tool?: { messageID: string; @@ -296,7 +296,7 @@ const emitParsedStreamEvent = ( request_id?: string; permission?: string; patterns?: unknown; - metadata?: unknown; + target?: string; always?: unknown; created_at?: number; reply?: PermissionReply; @@ -370,7 +370,7 @@ const emitParsedStreamEvent = ( patterns: Array.isArray(parsed.patterns) ? parsed.patterns.filter((item): item is string => typeof item === "string") : [], - metadata: isObjectRecord(parsed.metadata) ? parsed.metadata : {}, + target: typeof parsed.target === "string" ? parsed.target : undefined, always: Array.isArray(parsed.always) ? parsed.always.filter((item): item is string => typeof item === "string") : [], -- 2.54.0 From ed9828befe0a1828a67c9498334b5170eec00204 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 20:16:58 +0800 Subject: [PATCH 176/281] fix(chat): hide actions while streaming --- src/components/chat/AgentTurn.tsx | 4 +++- src/components/chat/AgentWorkspace.tsx | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 5a5c794..07da8fb 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -39,6 +39,7 @@ import StopRounded from "@mui/icons-material/StopRounded"; type AgentTurnProps = { message: Message; + isStreaming: boolean; messageSpeechState: SpeechState; onSpeak: (messageId: string, text: string) => void; onPause: () => void; @@ -54,6 +55,7 @@ type AgentTurnProps = { export const AgentTurn = React.memo( ({ message, + isStreaming, messageSpeechState, onSpeak, onPause, @@ -277,7 +279,7 @@ export const AgentTurn = React.memo( </Stack> <AnimatePresence> - {isHovered && ( + {isHovered && !isStreaming && ( <motion.div initial={{ opacity: 0, scale: 0.9, y: 5 }} animate={{ opacity: 1, scale: 1, y: 0 }} diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 6add4ec..ed841de 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -36,6 +36,7 @@ type AgentWorkspaceProps = { type TurnListProps = { messages: Message[]; + isStreaming: boolean; speakingMessageId: string | null; speechState: SpeechState; onSpeak: (messageId: string, text: string) => void; @@ -55,6 +56,7 @@ const sameMessages = (left: Message[], right: Message[]) => const TurnListInner = ({ messages, + isStreaming, speakingMessageId, speechState, onSpeak, @@ -73,6 +75,7 @@ const TurnListInner = ({ <AgentTurn key={message.id} message={message} + isStreaming={isStreaming} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} onSpeak={onSpeak} onPause={onPauseSpeech} @@ -93,6 +96,7 @@ const TurnList = React.memo( TurnListInner, (prevProps, nextProps) => sameMessages(prevProps.messages, nextProps.messages) && + prevProps.isStreaming === nextProps.isStreaming && prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speechState === nextProps.speechState && prevProps.onSpeak === nextProps.onSpeak && @@ -274,6 +278,7 @@ export const AgentWorkspace = ({ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <TurnList messages={historyMessages} + isStreaming={isStreaming} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={onSpeak} @@ -290,6 +295,7 @@ export const AgentWorkspace = ({ {streamingMessage ? ( <TurnList messages={[streamingMessage]} + isStreaming={isStreaming} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={onSpeak} -- 2.54.0 From 22afdbf2e840b578b0de7b86b35933865a821fd6 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 8 Jun 2026 20:25:48 +0800 Subject: [PATCH 177/281] =?UTF-8?q?fix(chat):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A7=E4=BB=A3=E7=A0=81=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentPermissionRequests.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/chat/AgentPermissionRequests.tsx b/src/components/chat/AgentPermissionRequests.tsx index 7c94387..8906347 100644 --- a/src/components/chat/AgentPermissionRequests.tsx +++ b/src/components/chat/AgentPermissionRequests.tsx @@ -536,12 +536,13 @@ export const PermissionRequestGroup = ({ variant="caption" color="text.secondary" noWrap + title={primaryValue} sx={{ display: "block", fontFamily: permission.permission === "bash" ? "monospace" : undefined, }} > - {truncateText(primaryValue, 72)} + {primaryValue} </Typography> </Box> <Chip -- 2.54.0 From 7d966a5e91aec9c9d388b500f3d4dee2567e1fa0 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 9 Jun 2026 17:55:17 +0800 Subject: [PATCH 178/281] feat(map): add coordinate zoom action --- src/components/chat/ChatToolCallBlock.tsx | 56 +++++++++++++++++++ .../chat/hooks/useAgentToolActions.ts | 52 +++++++++++++++++ .../core/Controls/useToolbarChatActions.ts | 13 +++++ src/store/chatToolStore.ts | 7 +++ 4 files changed, 128 insertions(+) diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index a552a00..2b63abb 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -118,6 +118,12 @@ const TOOL_META: Record<string, ToolMeta> = { actionLabel: "定位到地图", color: "#3ba272", }, + zoom_to_map: { + label: "缩放到坐标", + icon: <LocationOnRounded sx={{ fontSize: 18 }} />, + actionLabel: "缩放到地图", + color: "#0ea5e9", + }, view_history: { label: "查看计算结果", icon: <TimelineRounded sx={{ fontSize: 18 }} />, @@ -176,6 +182,46 @@ function normalizeLocateIds(params: Record<string, unknown>): string[] { return []; } +function readFiniteNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function buildZoomTo3857Action( + params: Record<string, unknown>, +): Extract<ChatToolAction, { type: "zoom_to_map" }> | null { + const rawCoordinate = params.coordinate ?? params.coordinates ?? params.center; + const tuple = Array.isArray(rawCoordinate) + ? rawCoordinate + : [params.x ?? params.lon ?? params.longitude, params.y ?? params.lat ?? params.latitude]; + const x = readFiniteNumber(tuple[0]); + const y = readFiniteNumber(tuple[1]); + if (x === null || y === null) { + return null; + } + + const zoom = readFiniteNumber(params.zoom); + const durationMs = readFiniteNumber(params.duration_ms ?? params.durationMs); + const rawSourceCrs = params.source_crs ?? params.sourceCrs ?? params.crs; + const normalizedSourceCrs = + typeof rawSourceCrs === "string" ? rawSourceCrs.trim().toUpperCase() : ""; + const sourceCrs = + normalizedSourceCrs === "EPSG:4326" ? "EPSG:4326" : "EPSG:3857"; + return { + type: "zoom_to_map", + coordinate: [x, y], + sourceCrs, + zoom: zoom ?? undefined, + durationMs: durationMs ?? undefined, + }; +} + function getToolDescription(toolCall: ToolCall): string { const { params } = toolCall; const resolveScadaFeatureInfos = (): [string, string][] => { @@ -281,6 +327,14 @@ function getToolDescription(toolCall: ToolCall): string { case "render_junctions": { return (params.render_ref as string | undefined) ?? "渲染引用"; } + case "zoom_to_map": { + const action = buildZoomTo3857Action(params); + if (!action) { + return "地图坐标"; + } + const zoom = action.zoom === undefined ? "" : ` · zoom ${action.zoom}`; + return `${action.coordinate[0]}, ${action.coordinate[1]} · ${action.sourceCrs}${zoom}`; + } case APPLY_LAYER_STYLE_TOOL: { const payload = parseApplyLayerStylePayload(params); return payload ? describeApplyLayerStyle(payload) : "图层样式"; @@ -341,6 +395,8 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { (params.end as string | undefined), }); switch (toolCall.tool) { + case "zoom_to_map": + return buildZoomTo3857Action(params); case "locate_features": { const featureTypeRaw = params.feature_type; const featureType = diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts index ff9191a..b3ec0da 100644 --- a/src/components/chat/hooks/useAgentToolActions.ts +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -148,6 +148,46 @@ const compactNames = (names: string[]) => { : names.join(", "); }; +const readFiniteNumber = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const parseZoomTo3857Action = ( + params: Record<string, unknown>, +): Extract<ChatToolAction, { type: "zoom_to_map" }> | null => { + const rawCoordinate = params.coordinate ?? params.coordinates ?? params.center; + const tuple = Array.isArray(rawCoordinate) + ? rawCoordinate + : [params.x ?? params.lon ?? params.longitude, params.y ?? params.lat ?? params.latitude]; + const x = readFiniteNumber(tuple[0]); + const y = readFiniteNumber(tuple[1]); + if (x === null || y === null) { + return null; + } + + const zoom = readFiniteNumber(params.zoom); + const durationMs = readFiniteNumber(params.duration_ms ?? params.durationMs); + const rawSourceCrs = params.source_crs ?? params.sourceCrs ?? params.crs; + const normalizedSourceCrs = + typeof rawSourceCrs === "string" ? rawSourceCrs.trim().toUpperCase() : ""; + const sourceCrs = + normalizedSourceCrs === "EPSG:4326" ? "EPSG:4326" : "EPSG:3857"; + return { + type: "zoom_to_map", + coordinate: [x, y], + sourceCrs, + zoom: zoom ?? undefined, + durationMs: durationMs ?? undefined, + }; +}; + const buildLocateArtifact = ( tool: string, params: Record<string, unknown>, @@ -190,6 +230,18 @@ const buildToolAction = ( }; } + if (tool === "zoom_to_map") { + const action = parseZoomTo3857Action(params); + return { + action, + kind: "map", + title: "缩放到地图坐标", + description: action + ? `${action.coordinate[0]}, ${action.coordinate[1]} (${action.sourceCrs})` + : "地图坐标", + }; + } + if (tool === "locate_features" || LOCATE_TOOL_CONFIG[tool]) { const locate = buildLocateArtifact(tool, params); return { diff --git a/src/components/olmap/core/Controls/useToolbarChatActions.ts b/src/components/olmap/core/Controls/useToolbarChatActions.ts index f29a982..7e472ea 100644 --- a/src/components/olmap/core/Controls/useToolbarChatActions.ts +++ b/src/components/olmap/core/Controls/useToolbarChatActions.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } fr import Feature from "ol/Feature"; import { GeoJSON } from "ol/format"; import Point from "ol/geom/Point"; +import { transform } from "ol/proj"; import { bbox, featureCollection } from "@turf/turf"; import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler"; @@ -110,6 +111,18 @@ export const useToolbarChatActions = ({ locateFeatures(action.ids, action.layer, action.geometryKind); break; } + case "zoom_to_map": { + const center = + action.sourceCrs === "EPSG:4326" + ? transform(action.coordinate, "EPSG:4326", "EPSG:3857") + : action.coordinate; + map?.getView().animate({ + center, + zoom: action.zoom ?? map.getView().getZoom() ?? 18, + duration: action.durationMs ?? 1000, + }); + break; + } case "view_history": { setChatPanelFeatureInfos(action.featureInfos); setChatPanelType(action.dataType); diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index 7a0e4d4..6147ee7 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -15,6 +15,13 @@ export type ChatToolAction = layer: string; geometryKind: "point" | "line"; } + | { + type: "zoom_to_map"; + coordinate: [number, number]; + sourceCrs?: "EPSG:3857" | "EPSG:4326"; + zoom?: number; + durationMs?: number; + } | { type: "view_history"; featureInfos: [string, string][]; -- 2.54.0 From 216c7b1ab9a120f8835e54fca38b685cad58a899 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 9 Jun 2026 18:18:22 +0800 Subject: [PATCH 179/281] docs: add repository guidelines --- AGENTS.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a0cfc22 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository is the TJWater web frontend built with Refine, Next.js, React, and MUI. Application source lives under the existing Next.js project folders. Reuse established page, component, provider, map, and chat patterns instead of adding parallel structures. Static assets and public files should remain in the existing asset/public locations. Build output (`.next/`), dependency folders, and local caches are generated and must not be edited by hand. + +Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`. + +## Build, Test, and Development Commands + +Use npm and Node 20 or newer: + +```bash +npm install +npm run dev +npm run lint +npm test +npm run test:coverage +npm run build +npm run start +``` + +`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build. + +## Coding Style & Naming Conventions + +Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for components, `camelCase` for variables/functions, and descriptive feature-oriented filenames. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused. + +## Testing Guidelines + +Tests use Jest with React Testing Library. Name tests `*.test.ts` or `*.test.tsx` near the related code when possible. Add tests for user-visible behavior, state transitions, route guards, data transforms, and map/chat interactions. Run `npm test` or `npm run test:coverage` before larger PRs. + +## Commit & Pull Request Guidelines + +History uses Conventional Commit messages such as `feat(map): add coordinate zoom action` and `fix(chat): hide raw permission metadata`, with occasional Chinese summaries. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`. + +PRs should include a UI/behavior summary, verification commands, screenshots for visual changes, and notes for changed environment variables or backend API expectations. + +## Security & Configuration Tips + +Do not commit `.env`, `.next/`, `node_modules/`, local caches, or private map/API tokens. Public build-time variables should be documented; sensitive values belong in Gitea secrets. -- 2.54.0 From d80a0719874ecc8e18af2b78db2d3fb88a7a5f8d Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Tue, 9 Jun 2026 18:24:37 +0800 Subject: [PATCH 180/281] =?UTF-8?q?=E5=88=A0=E9=99=A4=20copilot=20?= =?UTF-8?q?=E8=87=AA=E8=BF=B0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 60 --------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 0fea55e..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,60 +0,0 @@ -# Copilot Instructions for TJWaterFrontend_Refine - -## Environment Setup - -1. **Node.js**: Ensure you have Node.js v18 or later installed. -2. **Dependencies**: Run `npm install` to install all project dependencies. -3. **Environment Variables**: Create a `.env.local` file in the root directory with - -Using bash setup dependencies: - -```bash -npm install -``` - -## Build, Test, and Lint - -- **Dev Server**: `npm run dev` (Runs with increased memory limit: `--max_old_space_size=4096`) -- **Build**: `npm run build` -- **Lint**: `npm run lint` (ESLint) -- **Test**: `npm run test` (Jest) - - Run a specific test file: `npm run test -- <path/to/file>` - - Run a specific test case: `npm run test -- -t 'test name'` - -## High-Level Architecture - -- **Framework**: **Next.js 16 (App Router)** integrated with **Refine** (`@refinedev/core`). -- **Routing**: - - Routes are defined in `src/app`. - - Refine resources (e.g., `/network-simulation`, `/hydraulic-simulation/*`) map directly to these routes. - - Configuration is central in `src/app/_refine_context.tsx`. -- **State Management**: - - **Global App State**: **Zustand** (`src/store`). - - **Server State**: Managed by Refine hooks (`useList`, `useOne`, etc.) via **React Query**. -- **Authentication**: - - **NextAuth.js** handling Keycloak integration. - - Session token is synced to Zustand (`useAuthStore`) in `RefineContext`. -- **Data Layer**: - - Custom Data Provider: `src/providers/data-provider`. - - API Utilities: `src/lib/api.ts`, `src/lib/apiFetch.ts`. -- **UI & Styling**: - - **Material UI (MUI)**: Primary component library (`@mui/material`, `@refinedev/mui`). - - **Tailwind CSS v4**: Utility classes for layout and custom styling (`@tailwindcss/postcss`). - - **Mapping**: OpenLayers (`ol`), deck.gl, Turf.js. - - **Charts**: ECharts, MUI X Charts. - -## Key Conventions - -- **Refine Integration**: - - Use Refine hooks (`useTable`, `useForm`, `useNavigation`) for data-heavy components. - - Resources are defined in the `<Refine>` component in `src/app/_refine_context.tsx`. -- **Project Structure**: - - `src/components/`: Grouped by feature (e.g., `olmap`, `project`) or common UI elements. - - `src/lib/`: Utility functions and API helpers. - - `src/providers/`: Refine providers (data, etc.). -- **Imports**: - - Use absolute imports with `@/` alias (e.g., `@/components`, `@/store`, `@/lib`). - - _Note_: `@libs` alias in tsconfig points to non-existent `src/libs` folder; prefer `@/lib`. -- **Styling**: - - Prefer MUI components for standard UI elements. - - Use Tailwind utility classes for layout and custom overrides. -- 2.54.0 From 0501afaced7d842248ceb8d6ec5f28078b72d530 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 16:19:39 +0800 Subject: [PATCH 181/281] fix(chat): normalize chart tool data --- src/components/chat/AgentArtifactPanel.tsx | 10 +- src/components/chat/AgentTurn.tsx | 5 +- src/components/chat/ChatInlineChart.test.ts | 49 +++++++ src/components/chat/ChatInlineChart.tsx | 155 +++++++++++++++++++- 4 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 src/components/chat/ChatInlineChart.test.ts diff --git a/src/components/chat/AgentArtifactPanel.tsx b/src/components/chat/AgentArtifactPanel.tsx index ab20934..820ba01 100644 --- a/src/components/chat/AgentArtifactPanel.tsx +++ b/src/components/chat/AgentArtifactPanel.tsx @@ -17,7 +17,6 @@ import SensorsRounded from "@mui/icons-material/SensorsRounded"; import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded"; import { ChatInlineChart } from "./ChatInlineChart"; -import type { ChatChartSeries } from "./ChatInlineChart"; import type { AgentArtifact } from "./GlobalChatbox.types"; const artifactIcon = (kind: AgentArtifact["kind"]) => { @@ -61,8 +60,13 @@ export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] } chart_type={ (artifact.params.chart_type as "line" | "bar" | "pie") ?? "line" } - x_data={(artifact.params.x_data as string[]) ?? []} - series={(artifact.params.series as ChatChartSeries[]) ?? []} + x_data={ + artifact.params.x_data ?? + artifact.params.xData ?? + artifact.params.labels ?? + artifact.params.categories + } + series={artifact.params.series} x_axis_name={(artifact.params.x_axis_name as string) ?? undefined} y_axis_name={(artifact.params.y_axis_name as string) ?? undefined} /> diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 07da8fb..7ed8f89 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -26,7 +26,6 @@ import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChatInlineChart } from "./ChatInlineChart"; -import type { ChatChartSeries } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; import { PermissionRequestGroup } from "./AgentPermissionRequests"; @@ -251,8 +250,8 @@ export const AgentTurn = React.memo( chart_type={ (p.chart_type as "line" | "bar" | "pie") ?? "line" } - x_data={(p.x_data as string[]) ?? []} - series={(p.series as ChatChartSeries[]) ?? []} + x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories} + series={p.series} x_axis_name={(p.x_axis_name as string) ?? undefined} y_axis_name={(p.y_axis_name as string) ?? undefined} /> diff --git a/src/components/chat/ChatInlineChart.test.ts b/src/components/chat/ChatInlineChart.test.ts new file mode 100644 index 0000000..083e7bc --- /dev/null +++ b/src/components/chat/ChatInlineChart.test.ts @@ -0,0 +1,49 @@ +import { normalizeChartData } from "./ChatInlineChart"; + +describe("normalizeChartData", () => { + it("keeps standard bar chart series data", () => { + const result = normalizeChartData(["A", "B"], [ + { name: "数量", data: [3, 5], type: "bar" }, + ]); + + expect(result).toEqual({ + xData: ["A", "B"], + series: [{ name: "数量", data: [3, 5], type: "bar" }], + }); + }); + + it("normalizes line chart point arrays into x labels and y values", () => { + const result = normalizeChartData(undefined, [ + { name: "压力", data: [["10:00", 12.5], ["11:00", 13.1]] }, + ]); + + expect(result).toEqual({ + xData: ["10:00", "11:00"], + series: [{ name: "压力", data: [12.5, 13.1], type: undefined }], + }); + }); + + it("normalizes pie chart point objects into a single series", () => { + const result = normalizeChartData(undefined, [ + { name: "低风险", value: 8 }, + { name: "高风险", value: 2 }, + ]); + + expect(result).toEqual({ + xData: ["低风险", "高风险"], + series: [{ name: "数据", data: [8, 2], type: undefined }], + }); + }); + + it("accepts a single series object", () => { + const result = normalizeChartData(["A", "B"], { + name: "流量", + values: ["1.2", "2.4"], + }); + + expect(result).toEqual({ + xData: ["A", "B"], + series: [{ name: "流量", data: [1.2, 2.4], type: undefined }], + }); + }); +}); diff --git a/src/components/chat/ChatInlineChart.tsx b/src/components/chat/ChatInlineChart.tsx index 1f7f9e4..80721d7 100644 --- a/src/components/chat/ChatInlineChart.tsx +++ b/src/components/chat/ChatInlineChart.tsx @@ -16,11 +16,25 @@ export interface ChatChartSeries { type?: "line" | "bar"; } +type RawChartPoint = + | number + | string + | [unknown, unknown] + | { x?: unknown; y?: unknown; time?: unknown; timestamp?: unknown; label?: unknown; name?: unknown; value?: unknown }; + +type RawChartSeries = { + name?: unknown; + data?: unknown; + points?: unknown; + values?: unknown; + type?: unknown; +}; + export interface ChatInlineChartProps { title?: string; chart_type?: "line" | "bar" | "pie"; - x_data?: string[]; - series?: ChatChartSeries[]; + x_data?: unknown; + series?: unknown; y_axis_name?: string; x_axis_name?: string; } @@ -37,23 +51,148 @@ const COLORS = [ "#ea7ccc", ]; +const toFiniteNumber = (value: unknown): number | null => { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +export const pointToLabelValue = ( + point: RawChartPoint, + fallbackLabel: string, +): { label: string; value: number } | null => { + const directValue = toFiniteNumber(point); + if (directValue !== null) { + return { label: fallbackLabel, value: directValue }; + } + + if (Array.isArray(point)) { + const value = toFiniteNumber(point[1]); + if (value === null) return null; + return { label: String(point[0] ?? fallbackLabel), value }; + } + + if (point && typeof point === "object") { + const value = toFiniteNumber(point.value ?? point.y); + if (value === null) return null; + const label = + point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel; + return { label: String(label), value }; + } + + return null; +}; + +const normalizeXData = (rawXData: unknown): string[] => + Array.isArray(rawXData) + ? rawXData.map((item) => String(item ?? "")).filter((item) => item.length > 0) + : []; + +const normalizeSeriesType = (type: unknown): "line" | "bar" | undefined => + type === "line" || type === "bar" ? type : undefined; + +const isRawChartPoint = (item: unknown): boolean => { + if (toFiniteNumber(item) !== null) return true; + if (Array.isArray(item)) return item.length >= 2 && toFiniteNumber(item[1]) !== null; + if (item && typeof item === "object") { + const rawItem = item as RawChartSeries & RawChartPoint; + return ( + rawItem.data === undefined && + rawItem.points === undefined && + rawItem.values === undefined && + toFiniteNumber(rawItem.value ?? rawItem.y) !== null + ); + } + return false; +}; + +const normalizeRawSeriesItems = (rawSeries: unknown): unknown[] => { + if (!Array.isArray(rawSeries)) { + return rawSeries && typeof rawSeries === "object" ? [rawSeries] : []; + } + + return rawSeries.length > 0 && rawSeries.every(isRawChartPoint) + ? [{ name: "数据", data: rawSeries }] + : rawSeries; +}; + +export const normalizeChartData = ( + rawXData: unknown, + rawSeries: unknown, +): { xData: string[]; series: ChatChartSeries[] } => { + const xData = normalizeXData(rawXData); + const rawSeriesItems = normalizeRawSeriesItems(rawSeries); + if (!rawSeriesItems.length) { + return { xData, series: [] }; + } + + const normalizedSeries = rawSeriesItems + .map((rawItem, seriesIndex): ChatChartSeries | null => { + const item = + rawItem && typeof rawItem === "object" && !Array.isArray(rawItem) + ? (rawItem as RawChartSeries) + : ({ data: rawItem } satisfies RawChartSeries); + const rawData = item.data ?? item.points ?? item.values; + if (!Array.isArray(rawData)) return null; + + const labelsFromPoints: string[] = []; + const data = rawData + .map((point, index) => { + const parsed = pointToLabelValue( + point as RawChartPoint, + xData[index] ?? `${index + 1}`, + ); + if (!parsed) return null; + labelsFromPoints[index] = parsed.label; + return parsed.value; + }) + .filter((value): value is number => value !== null); + + if (!data.length) return null; + if (!xData.length && labelsFromPoints.length) { + xData.push(...labelsFromPoints); + } + + return { + name: + typeof item.name === "string" && item.name.trim() + ? item.name + : `系列 ${seriesIndex + 1}`, + data, + type: normalizeSeriesType(item.type), + }; + }) + .filter((item): item is ChatChartSeries => Boolean(item)); + + return { xData, series: normalizedSeries }; +}; + export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ title, chart_type: chartType = "line", - x_data: xData, - series = [], + x_data, + series, y_axis_name: yAxisName, x_axis_name: xAxisName, }) => { const theme = useTheme(); + const { xData, series: chartSeries } = useMemo( + () => normalizeChartData(x_data, series), + [x_data, series], + ); const option = useMemo(() => { - if (!series.length) return null; + if (!chartSeries.length) return null; /* ---------- Pie chart ---------- */ if (chartType === "pie") { const pieData = - series[0]?.data.map((value, i) => ({ + chartSeries[0]?.data.map((value, i) => ({ name: xData?.[i] ?? `${i}`, value, })) ?? []; @@ -111,7 +250,7 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ xData && xData.length > 20 ? [{ type: "inside", start: 0, end: 100 }] : undefined, - series: series.map((s, i) => { + series: chartSeries.map((s, i) => { const color = COLORS[i % COLORS.length]; return { name: s.name, @@ -135,7 +274,7 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ }), color: COLORS, }; - }, [chartType, xData, series, title, yAxisName, xAxisName]); + }, [chartType, xData, chartSeries, title, yAxisName, xAxisName]); if (!option) { return ( -- 2.54.0 From 213a01ff7dc3a2b421f11cbddf66417ab6659b22 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 16:27:07 +0800 Subject: [PATCH 182/281] fix(chat): narrow chart point types --- src/components/chat/ChatInlineChart.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/chat/ChatInlineChart.tsx b/src/components/chat/ChatInlineChart.tsx index 80721d7..9715b14 100644 --- a/src/components/chat/ChatInlineChart.tsx +++ b/src/components/chat/ChatInlineChart.tsx @@ -20,7 +20,17 @@ type RawChartPoint = | number | string | [unknown, unknown] - | { x?: unknown; y?: unknown; time?: unknown; timestamp?: unknown; label?: unknown; name?: unknown; value?: unknown }; + | RawChartPointObject; + +type RawChartPointObject = { + x?: unknown; + y?: unknown; + time?: unknown; + timestamp?: unknown; + label?: unknown; + name?: unknown; + value?: unknown; +}; type RawChartSeries = { name?: unknown; @@ -100,7 +110,7 @@ const isRawChartPoint = (item: unknown): boolean => { if (toFiniteNumber(item) !== null) return true; if (Array.isArray(item)) return item.length >= 2 && toFiniteNumber(item[1]) !== null; if (item && typeof item === "object") { - const rawItem = item as RawChartSeries & RawChartPoint; + const rawItem = item as RawChartSeries & RawChartPointObject; return ( rawItem.data === undefined && rawItem.points === undefined && -- 2.54.0 From eee165c812d74850a59b8f9d898d433a455dd3ad Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 16:53:30 +0800 Subject: [PATCH 183/281] fix(chat): render chart artifacts --- src/components/chat/AgentTurn.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 7ed8f89..92c1150 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -25,6 +25,7 @@ import { import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; +import { AgentArtifactPanel } from "./AgentArtifactPanel"; import { ChatInlineChart } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; @@ -92,6 +93,19 @@ export const AgentTurn = React.memo( : [{ type: "text", content: answerContent }], [answerContent, isErrorMessage, isUser], ); + const hasInlineChart = contentSegments.some( + (segment) => + segment.type === "tool_call" && + (segment.toolCall.tool === "chart" || + segment.toolCall.tool === "show_chart"), + ); + const visibleArtifacts = useMemo( + () => + hasInlineChart + ? message.artifacts?.filter((artifact) => artifact.kind !== "chart") + : message.artifacts, + [hasInlineChart, message.artifacts], + ); if (isUser) { return ( @@ -275,6 +289,10 @@ export const AgentTurn = React.memo( })} </Stack> </Box> + + {visibleArtifacts?.length ? ( + <AgentArtifactPanel artifacts={visibleArtifacts} /> + ) : null} </Stack> <AnimatePresence> -- 2.54.0 From ab9e2a04201972a581c6619f599e5e3d4d837e76 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 17:05:37 +0800 Subject: [PATCH 184/281] fix(chat): show only chart artifacts --- src/components/chat/AgentTurn.tsx | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 92c1150..2d69838 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -25,7 +25,6 @@ import { import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; -import { AgentArtifactPanel } from "./AgentArtifactPanel"; import { ChatInlineChart } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; @@ -99,11 +98,11 @@ export const AgentTurn = React.memo( (segment.toolCall.tool === "chart" || segment.toolCall.tool === "show_chart"), ); - const visibleArtifacts = useMemo( + const visibleChartArtifacts = useMemo( () => hasInlineChart - ? message.artifacts?.filter((artifact) => artifact.kind !== "chart") - : message.artifacts, + ? [] + : (message.artifacts?.filter((artifact) => artifact.kind === "chart") ?? []), [hasInlineChart, message.artifacts], ); @@ -290,9 +289,25 @@ export const AgentTurn = React.memo( </Stack> </Box> - {visibleArtifacts?.length ? ( - <AgentArtifactPanel artifacts={visibleArtifacts} /> - ) : null} + {visibleChartArtifacts.map((artifact) => ( + <ChatInlineChart + key={artifact.id} + title={(artifact.params.title as string) ?? artifact.title} + chart_type={ + (artifact.params.chart_type as "line" | "bar" | "pie") ?? + "line" + } + x_data={ + artifact.params.x_data ?? + artifact.params.xData ?? + artifact.params.labels ?? + artifact.params.categories + } + series={artifact.params.series} + x_axis_name={(artifact.params.x_axis_name as string) ?? undefined} + y_axis_name={(artifact.params.y_axis_name as string) ?? undefined} + /> + ))} </Stack> <AnimatePresence> -- 2.54.0 From 9c0a7a2864401d1d29918a0adad04a8e4cb5edd0 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 17:51:28 +0800 Subject: [PATCH 185/281] fix(chat): add history loading skeletons --- .../chat/AgentHistoryPanel.test.tsx | 50 ++++++ src/components/chat/AgentHistoryPanel.tsx | 73 +++++++-- src/components/chat/AgentWorkspace.test.tsx | 16 +- src/components/chat/AgentWorkspace.tsx | 150 +++++++++++++----- src/components/chat/GlobalChatbox.tsx | 12 +- .../chat/hooks/useAgentChatSession.ts | 10 +- 6 files changed, 254 insertions(+), 57 deletions(-) diff --git a/src/components/chat/AgentHistoryPanel.test.tsx b/src/components/chat/AgentHistoryPanel.test.tsx index 8dc4227..d85f9f1 100644 --- a/src/components/chat/AgentHistoryPanel.test.tsx +++ b/src/components/chat/AgentHistoryPanel.test.tsx @@ -8,6 +8,56 @@ const renderWithTheme = (ui: React.ReactElement) => render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>); describe("AgentHistoryPanel", () => { + it("shows skeleton rows while history sessions are loading", () => { + renderWithTheme( + <AgentHistoryPanel + sessions={[]} + isLoadingSessions + onNewSession={jest.fn()} + onRenameSession={jest.fn()} + onSelectSession={jest.fn()} + onDeleteSession={jest.fn()} + />, + ); + + expect(screen.getByLabelText("正在加载历史会话")).toBeInTheDocument(); + expect(screen.queryByText("暂无历史会话")).not.toBeInTheDocument(); + }); + + it("disables the loading history session item", () => { + const onSelectSession = jest.fn(); + const onRenameSession = jest.fn(); + const onDeleteSession = jest.fn(); + + renderWithTheme( + <AgentHistoryPanel + sessions={[ + { + id: "session-loading", + title: "正在加载的会话", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ]} + loadingSessionId="session-loading" + onNewSession={jest.fn()} + onRenameSession={onRenameSession} + onSelectSession={onSelectSession} + onDeleteSession={onDeleteSession} + />, + ); + + expect(screen.queryByText("正在加载的会话")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "修改会话标题" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "删除会话" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("正在加载会话 正在加载的会话")); + + expect(onSelectSession).not.toHaveBeenCalled(); + expect(onRenameSession).not.toHaveBeenCalled(); + expect(onDeleteSession).not.toHaveBeenCalled(); + }); + it("renames a history session from the list", () => { const onRenameSession = jest.fn(); diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx index 668c5a2..cfc9b9a 100644 --- a/src/components/chat/AgentHistoryPanel.tsx +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -13,6 +13,7 @@ import { Divider, IconButton, Paper, + Skeleton, Stack, TextField, Tooltip, @@ -34,9 +35,11 @@ type AgentHistoryPanelProps = { sessions: ChatSessionSummary[]; activeSessionId?: string; isHydrating?: boolean; + isLoadingSessions?: boolean; + loadingSessionId?: string; onNewSession: () => void; onRenameSession: (sessionId: string, title: string) => void; - onSelectSession: (sessionId: string) => void; + onSelectSession: (sessionId: string, title: string) => void; onDeleteSession: (sessionId: string) => void; }; @@ -76,6 +79,8 @@ export const AgentHistoryPanel = ({ sessions, activeSessionId, isHydrating = false, + isLoadingSessions = false, + loadingSessionId, onNewSession, onRenameSession, onSelectSession, @@ -127,6 +132,30 @@ export const AgentHistoryPanel = ({ (session) => session.id === pendingDeleteSessionId, ); + const renderSessionListSkeleton = () => ( + <Stack spacing={1} aria-label="正在加载历史会话"> + {Array.from({ length: 6 }, (_, index) => ( + <Paper + key={index} + elevation={0} + sx={{ + px: 1.25, + py: 1, + borderRadius: 3, + bgcolor: alpha("#fff", 0.48), + border: `1px solid ${alpha("#fff", 0.68)}`, + boxShadow: `0 4px 12px ${alpha("#000", 0.025)}`, + }} + > + <Stack spacing={0.75} sx={{ minHeight: 46, justifyContent: "center" }}> + <Skeleton variant="text" width={`${72 - (index % 3) * 12}%`} height={18} /> + <Skeleton variant="text" width="32%" height={14} /> + </Stack> + </Paper> + ))} + </Stack> + ); + const handleStartRename = (sessionId: string, title: string) => { setEditingSessionId(sessionId); setDraftTitle(title); @@ -215,7 +244,9 @@ export const AgentHistoryPanel = ({ <Divider sx={{ borderColor: alpha("#fff", 0.6) }} /> <Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}> - {sessions.length === 0 ? ( + {isLoadingSessions ? ( + renderSessionListSkeleton() + ) : sessions.length === 0 ? ( <Stack alignItems="center" justifyContent="center" @@ -271,27 +302,42 @@ export const AgentHistoryPanel = ({ <Stack spacing={1}> {groupSessions.map((session) => { const isActive = session.id === activeSessionId; + const isLoading = session.id === loadingSessionId; return ( <Paper key={session.id} elevation={0} + aria-label={isLoading ? `正在加载会话 ${session.title}` : undefined} onClick={() => { - if (editingSessionId === session.id) return; - onSelectSession(session.id); + if (editingSessionId === session.id || isLoading) return; + onSelectSession(session.id, session.title); }} sx={{ px: 1.25, py: 1, borderRadius: 3, - cursor: isHydrating ? "default" : "pointer", - bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56), - border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`, - boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`, + cursor: isHydrating || isLoading ? "default" : "pointer", + bgcolor: + isActive || isLoading + ? alpha("#00acc1", 0.12) + : alpha("#fff", 0.56), + border: `1px solid ${ + isActive || isLoading + ? alpha("#00acc1", 0.25) + : alpha("#fff", 0.72) + }`, + boxShadow: + isActive || isLoading + ? `0 8px 20px ${alpha("#00acc1", 0.12)}` + : `0 4px 12px ${alpha("#000", 0.03)}`, transition: "all 0.2s ease", - pointerEvents: isHydrating ? "none" : "auto", + pointerEvents: isHydrating || isLoading ? "none" : "auto", "&:hover": { - bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86), + bgcolor: + isActive || isLoading + ? alpha("#00acc1", 0.14) + : alpha("#fff", 0.86), borderColor: alpha("#00acc1", 0.2), }, }} @@ -382,6 +428,11 @@ export const AgentHistoryPanel = ({ <CloseRounded sx={{ fontSize: 16 }} /> </IconButton> </Stack> + ) : isLoading ? ( + <Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}> + <Skeleton variant="text" width="74%" height={18} /> + <Skeleton variant="text" width="34%" height={14} sx={{ mt: 0.5 }} /> + </Box> ) : pendingDeleteSessionId === session.id ? ( <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}> <Box @@ -437,7 +488,7 @@ export const AgentHistoryPanel = ({ )} </Box> - {!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && ( + {!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && ( <Stack direction="row" spacing={0.25}> <Tooltip title="修改会话标题"> <span> diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index d63f033..237d903 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @next/next/no-img-element */ import "@testing-library/jest-dom"; import React from "react"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { AgentWorkspace } from "./AgentWorkspace"; import type { Message } from "./GlobalChatbox.types"; @@ -51,6 +51,20 @@ describe("AgentWorkspace", () => { renderCounts.clear(); }); + it("shows a loading skeleton instead of the empty state while switching history sessions", () => { + render( + <AgentWorkspace + {...defaultProps} + isStreaming={false} + isLoadingSession + messages={[]} + />, + ); + + expect(screen.getByLabelText("正在加载历史记录")).toBeInTheDocument(); + expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument(); + }); + it("keeps stable history turns from re-rendering while the last assistant message streams", () => { const userMessage: Message = { id: "user-1", diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index ed841de..e1ef132 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; +import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; @@ -20,6 +20,7 @@ import type { type AgentWorkspaceProps = { messages: Message[]; isStreaming: boolean; + isLoadingSession?: boolean; bottomRef: React.RefObject<HTMLDivElement | null>; speakingMessageId: string | null; speechState: SpeechState; @@ -226,9 +227,72 @@ const EmptyState = () => { ); }; +const SessionLoadingSkeleton = () => ( + <Stack + spacing={2.25} + aria-label="正在加载历史记录" + sx={{ width: "100%", maxWidth: 760, alignSelf: "stretch" }} + > + {Array.from({ length: 2 }, (_, turnIndex) => ( + <Stack key={turnIndex} spacing={1.25}> + <Stack direction="row" justifyContent="flex-end"> + <Paper + elevation={0} + sx={{ + width: turnIndex === 0 ? "72%" : "64%", + maxWidth: "86%", + p: 1.75, + borderRadius: 5, + borderBottomRightRadius: 2, + bgcolor: alpha("#00acc1", 0.16), + border: `1px solid ${alpha("#00acc1", 0.12)}`, + boxShadow: `0 8px 24px -12px ${alpha("#00acc1", 0.35)}`, + }} + > + <Stack spacing={0.85}> + <Skeleton variant="text" width="76%" height={18} /> + <Skeleton variant="text" width="48%" height={15} /> + </Stack> + </Paper> + </Stack> + + <Stack direction="row" spacing={1.5} alignItems="flex-start"> + <Skeleton + variant="circular" + width={34} + height={34} + sx={{ bgcolor: alpha("#00acc1", 0.12), flexShrink: 0, mt: 0.25 }} + /> + <Paper + elevation={0} + sx={{ + flex: 1, + minWidth: 0, + p: 2, + borderRadius: 5, + bgcolor: alpha("#ffffff", 0.52), + border: `1px solid ${alpha("#fff", 0.72)}`, + boxShadow: `0 10px 30px -10px ${alpha("#000", 0.06)}`, + }} + > + <Stack spacing={1}> + <Skeleton variant="text" width="38%" height={16} /> + <Skeleton variant="text" width="94%" height={16} /> + <Skeleton variant="text" width={turnIndex === 0 ? "88%" : "82%"} height={16} /> + <Skeleton variant="text" width={turnIndex === 0 ? "78%" : "70%"} height={16} /> + <Skeleton variant="rounded" width="100%" height={turnIndex === 0 ? 104 : 76} sx={{ borderRadius: 2 }} /> + </Stack> + </Paper> + </Stack> + </Stack> + ))} + </Stack> +); + export const AgentWorkspace = ({ messages, isStreaming, + isLoadingSession = false, bottomRef, speakingMessageId, speechState, @@ -270,49 +334,55 @@ export const AgentWorkspace = ({ zIndex: 5, }} > - <AnimatePresence initial={false}> - {messages.length === 0 ? <EmptyState /> : null} - </AnimatePresence> + {isLoadingSession ? ( + <SessionLoadingSkeleton /> + ) : ( + <> + <AnimatePresence initial={false}> + {messages.length === 0 ? <EmptyState /> : null} + </AnimatePresence> - {messages.length > 0 ? ( - <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> - <TurnList - messages={historyMessages} - isStreaming={isStreaming} - speakingMessageId={speakingMessageId} - speechState={speechState} - onSpeak={onSpeak} - onPauseSpeech={onPauseSpeech} - onResumeSpeech={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - onCreateBranch={onCreateBranch} - onReplyPermission={onReplyPermission} - onReplyQuestion={onReplyQuestion} - onRejectQuestion={onRejectQuestion} - /> + {messages.length > 0 ? ( + <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> + <TurnList + messages={historyMessages} + isStreaming={isStreaming} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={onSpeak} + onPauseSpeech={onPauseSpeech} + onResumeSpeech={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onCreateBranch={onCreateBranch} + onReplyPermission={onReplyPermission} + onReplyQuestion={onReplyQuestion} + onRejectQuestion={onRejectQuestion} + /> - {streamingMessage ? ( - <TurnList - messages={[streamingMessage]} - isStreaming={isStreaming} - speakingMessageId={speakingMessageId} - speechState={speechState} - onSpeak={onSpeak} - onPauseSpeech={onPauseSpeech} - onResumeSpeech={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - onCreateBranch={onCreateBranch} - onReplyPermission={onReplyPermission} - onReplyQuestion={onReplyQuestion} - onRejectQuestion={onRejectQuestion} - /> + {streamingMessage ? ( + <TurnList + messages={[streamingMessage]} + isStreaming={isStreaming} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={onSpeak} + onPauseSpeech={onPauseSpeech} + onResumeSpeech={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onCreateBranch={onCreateBranch} + onReplyPermission={onReplyPermission} + onReplyQuestion={onReplyQuestion} + onRejectQuestion={onRejectQuestion} + /> + ) : null} + </Box> ) : null} - </Box> - ) : null} + </> + )} - {showTypingIndicator ? ( + {!isLoadingSession && showTypingIndicator ? ( <motion.div initial={{ opacity: 0, y: 10, scale: 0.94 }} animate={{ opacity: 1, y: 0, scale: 1 }} diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 7988feb..7871814 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -68,6 +68,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { chatSessions, activeSessionId, isHydrating, + loadingSessionId, isStreaming, sessionTitle, sendPrompt, @@ -159,9 +160,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { }, []); const handleSelectSession = useCallback( - (sessionId: string) => { + (sessionId: string, title: string) => { composerRef.current?.clear(); - void switchSession(sessionId); + void switchSession(sessionId, title); }, [switchSession], ); @@ -326,12 +327,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { sessions={chatSessions} activeSessionId={activeSessionId} isHydrating={isHydrating} + isLoadingSessions={isHydrating && chatSessions.length === 0} + loadingSessionId={loadingSessionId} onNewSession={() => { handleNewConversation(); setIsHistoryOpen(false); }} - onSelectSession={(id) => { - handleSelectSession(id); + onSelectSession={(id, title) => { + handleSelectSession(id, title); setIsHistoryOpen(false); }} onRenameSession={handleRenameSession} @@ -343,6 +346,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { <AgentWorkspace messages={messages} isStreaming={isStreaming} + isLoadingSession={Boolean(loadingSessionId)} bottomRef={bottomRef} speakingMessageId={speakingMessageId} speechState={speechState} diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 04cde02..b99fad9 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -27,6 +27,7 @@ export const useAgentChatSession = ({ const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); const [isStreaming, setIsStreaming] = useState(false); const [isHydrating, setIsHydrating] = useState(true); + const [loadingSessionId, setLoadingSessionId] = useState<string | undefined>(undefined); const abortRef = useRef<AbortController | null>(null); const sessionIdRef = useRef<string | undefined>(undefined); const messagesRef = useRef<Message[]>([]); @@ -756,12 +757,17 @@ export const useAgentChatSession = ({ }, [isHydrating, isStreaming]); const switchSession = useCallback( - async (nextSessionId: string) => { + async (nextSessionId: string, optimisticTitle?: string) => { if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) { return; } setIsHydrating(true); + setLoadingSessionId(nextSessionId); + const nextTitle = optimisticTitle?.trim(); + if (nextTitle) { + setSessionTitle(nextTitle); + } try { const [nextState, sessions] = await Promise.all([ loadChatSessionById(nextSessionId), @@ -785,6 +791,7 @@ export const useAgentChatSession = ({ } catch (error) { console.error("[GlobalChatbox] Failed to switch chat session:", error); } finally { + setLoadingSessionId(undefined); setIsHydrating(false); } }, @@ -932,6 +939,7 @@ export const useAgentChatSession = ({ chatSessions, activeSessionId: sessionIdRef.current, isHydrating, + loadingSessionId, isStreaming, sessionTitle, sessionId, -- 2.54.0 From e2a6bb0e7d1e9d976ede4334e3a1d51bbbb60447 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 19:29:45 +0800 Subject: [PATCH 186/281] refactor(chat): remove frontend state saves --- src/components/chat/chatStorage.test.ts | 141 +++++++++++++----- src/components/chat/chatStorage.ts | 86 ----------- .../chat/hooks/agentChatSessionState.ts | 9 -- .../useAgentChatSession.actions.test.tsx | 4 - .../useAgentChatSession.lifecycle.test.tsx | 23 +-- .../chat/hooks/useAgentChatSession.ts | 104 +------------ 6 files changed, 112 insertions(+), 255 deletions(-) diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index 1575f86..7ebe12a 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -1,6 +1,9 @@ import { createEmptyChatState, - saveActiveChatState, + deleteChatSession, + listChatSessions, + loadChatSessionById, + updateChatSessionTitle, } from "./chatStorage"; const apiFetch = jest.fn(); @@ -9,7 +12,7 @@ jest.mock("@/lib/apiFetch", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args), })); -describe("chatStorage backend-only persistence", () => { +describe("chatStorage backend session operations", () => { beforeEach(() => { apiFetch.mockReset(); }); @@ -25,46 +28,106 @@ describe("chatStorage backend-only persistence", () => { expect(apiFetch).not.toHaveBeenCalled(); }); - it("creates a backend conversation when saving the first non-empty state", async () => { - apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { - if (url.endsWith("/api/v1/agent/chat/session")) { - expect(init?.method).toBe("POST"); - return { - ok: true, - json: async () => ({ session_id: "chat-new-1" }), - } as Response; - } - - if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) { - expect(init?.method).toBe("PUT"); - expect(JSON.parse(String(init?.body))).toMatchObject({ - title: "新对话", - is_title_manually_edited: false, - }); - return { - ok: true, - json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }), - } as Response; - } - - throw new Error(`Unexpected request ${url}`); - }); - - const savedSessionId = await saveActiveChatState( - { - title: "新对话", - isTitleManuallyEdited: false, - messages: [ + it("lists backend sessions sorted by created time", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessions: [ { - id: "message-2", - role: "user", - content: "第一条消息", + id: "session-old", + title: "旧会话", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + { + id: "session-new", + title: "新会话", + created_at: "2026-01-03T00:00:00.000Z", + updated_at: "2026-01-03T00:00:00.000Z", + is_streaming: true, + run_status: "running", }, ], - sessionId: undefined, - }, - ); + }), + }); - expect(savedSessionId).toBe("chat-new-1"); + await expect(listChatSessions()).resolves.toEqual([ + expect.objectContaining({ + id: "session-new", + title: "新会话", + isStreaming: true, + runStatus: "running", + }), + expect.objectContaining({ + id: "session-old", + title: "旧会话", + }), + ]); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" }); + }); + + it("loads a backend session state", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "session-1", + title: "管网分析", + is_title_manually_edited: true, + messages: [{ id: "message-1", role: "user", content: "查压力" }], + is_streaming: false, + }), + }); + + await expect(loadChatSessionById("session-1")).resolves.toMatchObject({ + title: "管网分析", + isTitleManuallyEdited: true, + sessionId: "session-1", + messages: [{ id: "message-1", role: "user", content: "查压力" }], + }); + expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1"); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" }); + }); + + it("updates a backend session title through the title endpoint", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "", + }); + + await updateChatSessionTitle("session-1", " 新标题 ", { + isTitleManuallyEdited: true, + }); + + expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title"); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" }); + expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({ + title: "新标题", + is_title_manually_edited: true, + }); + }); + + it("deletes a backend session and returns the next active session id", async () => { + apiFetch + .mockResolvedValueOnce({ + ok: true, + text: async () => "", + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessions: [ + { + id: "session-next", + title: "下一会话", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }, + ], + }), + }); + + await expect(deleteChatSession("session-1")).resolves.toBe("session-next"); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "DELETE" }); + expect(apiFetch.mock.calls[1][1]).toMatchObject({ method: "GET" }); }); }); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 30ee6ff..dc50618 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -27,13 +27,6 @@ export const createEmptyChatState = (): LoadedChatState => ({ const sanitizeMessages = (messages: Message[] | undefined) => Array.isArray(messages) ? cloneMessages(messages) : []; -const hasChatContent = (state: { - messages: Message[]; - sessionId?: string; -}) => - state.messages.length > 0 || - Boolean(state.sessionId); - const compareSessionsByAnchorTime = ( left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">, right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">, @@ -113,64 +106,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta }; }; -const createBackendChatSession = async (payload?: { - sessionId?: string; - parentSessionId?: string; -}) => { - const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - session_id: payload?.sessionId, - parent_session_id: payload?.parentSessionId, - }), - projectHeaderMode: "include", - userHeaderMode: "include", - skipAuthRedirect: true, - }); - if (!response.ok) { - throw new Error(await response.text()); - } - const body = (await response.json()) as { - session_id?: string; - }; - const sessionId = body.session_id?.trim(); - if (!sessionId) { - throw new Error("backend did not return session_id"); - } - return sessionId; -}; - -const saveBackendChatState = async ( - sessionId: string, - state: LoadedChatState, -): Promise<string> => { - const response = await apiFetch( - `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - title: normalizeTitle(state.title), - is_title_manually_edited: state.isTitleManuallyEdited ?? false, - messages: sanitizeMessages(state.messages), - }), - projectHeaderMode: "include", - userHeaderMode: "include", - skipAuthRedirect: true, - }, - ); - if (!response.ok) { - throw new Error(await response.text()); - } - const payload = (await response.json()) as { id?: string; session_id?: string }; - return payload.id ?? payload.session_id ?? sessionId; -}; - const updateBackendChatSessionTitle = async ( sessionId: string, title: string, @@ -212,27 +147,6 @@ const deleteBackendChatSession = async (sessionId: string) => { } }; -export const saveActiveChatState = async ( - state: LoadedChatState, -): Promise<string | undefined> => { - if (typeof window === "undefined") return state.sessionId; - - if (!hasChatContent(state)) { - return undefined; - } - - let backendSessionId = state.sessionId; - if (!backendSessionId) { - backendSessionId = await createBackendChatSession(); - } - - const savedSessionId = await saveBackendChatState(backendSessionId, { - ...state, - sessionId: backendSessionId, - }); - return savedSessionId; -}; - export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { if (typeof window === "undefined") return []; return await fetchBackendChatSessions(); diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts index 2961f9e..7393b4f 100644 --- a/src/components/chat/hooks/agentChatSessionState.ts +++ b/src/components/chat/hooks/agentChatSessionState.ts @@ -7,19 +7,10 @@ import type { import type { AgentPermissionRequest, ChatProgress, - LoadedChatState, Message, } from "../GlobalChatbox.types"; import { createId } from "../GlobalChatbox.utils"; -export const createPersistedStateKey = (state: LoadedChatState) => - JSON.stringify({ - title: state.title ?? null, - isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, - sessionId: state.sessionId ?? null, - messages: state.messages, - }); - export const upsertProgress = ( progress: ChatProgress[] | undefined, event: StreamEvent & { type: "progress" }, diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx index 5b7e868..d7a5145 100644 --- a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({ const listChatSessions = jest.fn(); const deleteChatSession = jest.fn(); -const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ @@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({ messages: [], sessionId: "session-loaded", })), - saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); @@ -50,7 +48,6 @@ describe("useAgentChatSession", () => { beforeEach(() => { listChatSessions.mockReset(); deleteChatSession.mockReset(); - saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); @@ -65,7 +62,6 @@ describe("useAgentChatSession", () => { jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); deleteChatSession.mockImplementation(async () => undefined); - saveActiveChatState.mockImplementation(async (state) => state.sessionId); updateChatSessionTitle.mockImplementation(async () => undefined); }); diff --git a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx index 4601ec0..b204bb2 100644 --- a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx @@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({ const listChatSessions = jest.fn(); const deleteChatSession = jest.fn(); -const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ @@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({ messages: [], sessionId: "session-loaded", })), - saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); @@ -50,7 +48,6 @@ describe("useAgentChatSession", () => { beforeEach(() => { listChatSessions.mockReset(); deleteChatSession.mockReset(); - saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); @@ -65,7 +62,6 @@ describe("useAgentChatSession", () => { jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); deleteChatSession.mockImplementation(async () => undefined); - saveActiveChatState.mockImplementation(async (state) => state.sessionId); updateChatSessionTitle.mockImplementation(async () => undefined); }); @@ -190,7 +186,7 @@ describe("useAgentChatSession lifecycle and resume", () => { ); }); - it("persists a new conversation only after the stream is done", async () => { + it("does not autosave full messages after the stream is done", async () => { listChatSessions.mockResolvedValue([]); let emitStreamEvent: ((event: StreamEvent) => void) | undefined; jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { @@ -220,8 +216,6 @@ describe("useAgentChatSession lifecycle and resume", () => { jest.advanceTimersByTime(200); }); - expect(saveActiveChatState).not.toHaveBeenCalled(); - act(() => { emitStreamEvent?.({ type: "token", @@ -234,8 +228,6 @@ describe("useAgentChatSession lifecycle and resume", () => { jest.advanceTimersByTime(200); }); - expect(saveActiveChatState).not.toHaveBeenCalled(); - act(() => { emitStreamEvent?.({ type: "done", @@ -247,14 +239,11 @@ describe("useAgentChatSession lifecycle and resume", () => { jest.advanceTimersByTime(200); }); - await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1)); - expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({ - sessionId: "chat-stream-1", - messages: [ - expect.objectContaining({ role: "user", content: "第一条消息" }), - expect.objectContaining({ role: "assistant", content: "收到" }), - ], - }); + expect(result.current.messages).toEqual([ + expect.objectContaining({ role: "user", content: "第一条消息" }), + expect.objectContaining({ role: "assistant", content: "收到" }), + ]); + expect(result.current.activeSessionId).toBe("chat-stream-1"); } finally { jest.useRealTimers(); } diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index b99fad9..0c5b9d9 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -4,10 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; import type { PermissionReply, StreamEvent } from "@/lib/chatStream"; -import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types"; +import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types"; import { cloneMessages } from "../GlobalChatbox.utils"; -import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage"; -import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; +import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage"; +import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; export const useAgentChatSession = ({ @@ -17,7 +17,6 @@ export const useAgentChatSession = ({ getModel, getApprovalMode, }: UseAgentChatSessionOptions) => { - const hydrationCompletedRef = useRef(false); const hydrationNonceRef = useRef(0); const [messages, setMessages] = useState<Message[]>([]); @@ -35,14 +34,6 @@ export const useAgentChatSession = ({ const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef<Promise<void> | null>(null); const titleUpdateNonceRef = useRef(0); - const lastPersistedStateKeyRef = useRef( - createPersistedStateKey({ - sessionId: undefined, - title: undefined, - isTitleManuallyEdited: false, - messages: [], - }), - ); useEffect(() => { sessionIdRef.current = sessionId; @@ -62,17 +53,9 @@ export const useAgentChatSession = ({ const hydrate = async () => { setIsHydrating(true); - hydrationCompletedRef.current = false; if (!projectId) { sessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - }); - hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; setMessages([]); @@ -93,8 +76,6 @@ export const useAgentChatSession = ({ if (cancelled) return; sessionIdRef.current = loadedState.sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState); - hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; @@ -127,51 +108,6 @@ export const useAgentChatSession = ({ }; }, [projectId]); - useEffect(() => { - if (!projectId || isHydrating || !hydrationCompletedRef.current) return; - - const currentHydrationNonce = hydrationNonceRef.current; - const persistTimer = window.setTimeout(() => { - if (isStreaming) { - return; - } - - const state: LoadedChatState = { - title: sessionTitle, - isTitleManuallyEdited: isSessionTitleManuallyEdited, - messages, - sessionId, - }; - - const currentStateKey = createPersistedStateKey(state); - if (currentStateKey === lastPersistedStateKeyRef.current) { - return; - } - - void saveActiveChatState(state) - .then((sessionId) => { - if (hydrationNonceRef.current !== currentHydrationNonce) return; - sessionIdRef.current = sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - ...state, - sessionId, - }); - return listChatSessions(); - }) - .then((sessions) => { - if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return; - setChatSessions(sessions); - }) - .catch((error) => { - console.error("[GlobalChatbox] Failed to persist chat state:", error); - }); - }, 150); - - return () => { - window.clearTimeout(persistTimer); - }; - }, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); - const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { setMessages((prev) => prev.map((message) => @@ -226,12 +162,6 @@ export const useAgentChatSession = ({ const targetSessionId = event.sessionId || currentSessionId; if (targetSessionId === currentSessionId) { setSessionTitle(nextTitle); - lastPersistedStateKeyRef.current = createPersistedStateKey({ - sessionId: targetSessionId, - title: nextTitle, - isTitleManuallyEdited: false, - messages: messagesRef.current, - }); } if (targetSessionId) { const currentNonce = ++titleUpdateNonceRef.current; @@ -743,12 +673,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - title: "新对话", - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - }); setMessages([]); setSessionTitle("新对话"); setIsSessionTitleManuallyEdited(false); @@ -777,7 +701,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); @@ -821,12 +744,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - }); setMessages([]); setSessionTitle(undefined); setIsSessionTitleManuallyEdited(false); @@ -842,7 +759,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); @@ -884,18 +800,12 @@ export const useAgentChatSession = ({ if (sessionIdRef.current === targetSessionId) { setSessionTitle(normalizedTitle); setIsSessionTitleManuallyEdited(true); - lastPersistedStateKeyRef.current = createPersistedStateKey({ - sessionId: targetSessionId, - title: normalizedTitle, - isTitleManuallyEdited: true, - messages, - }); } } catch (error) { console.error("[GlobalChatbox] Failed to rename chat session:", error); } }, - [isHydrating, messages], + [isHydrating], ); const createBranch = useCallback( @@ -920,12 +830,6 @@ export const useAgentChatSession = ({ const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本"; setSessionTitle(forkTitle); try { - await saveActiveChatState({ - title: forkTitle, - isTitleManuallyEdited: false, - messages: copiedMessages, - sessionId: forkedSessionId, - }); setChatSessions(await listChatSessions()); } catch (error) { console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error); -- 2.54.0 From 1e872ca8739261ce5ea787f6282e709783438052 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 19:33:11 +0800 Subject: [PATCH 187/281] chore(chat): default to deepseek flash --- src/components/chat/GlobalChatbox.tsx | 2 +- src/lib/chatStream.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 7871814..a6b0e8d 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -29,7 +29,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(false); const [selectedModel, setSelectedModel] = useState<AgentModel>( - "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", ); const [approvalMode, setApprovalMode] = useState<AgentApprovalMode>("request"); diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 339eaa9..d4e32d7 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -60,7 +60,7 @@ describe("streamAgentChat", () => { await streamAgentChat({ message: "hi", - model: "deepseek/deepseek-v4-pro", + model: "deepseek/deepseek-v4-flash", onEvent: (event) => events.push(event), }); @@ -73,7 +73,7 @@ describe("streamAgentChat", () => { body: JSON.stringify({ message: "hi", session_id: undefined, - model: "deepseek/deepseek-v4-pro", + model: "deepseek/deepseek-v4-flash", approval_mode: undefined, }), }), -- 2.54.0 From 7d2ae87e39d794e68163a33134121f9f88ca9cc0 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 19:50:43 +0800 Subject: [PATCH 188/281] feat(chat): load model options from backend --- src/components/chat/AgentComposer.tsx | 75 +++++++++++-------- src/components/chat/GlobalChatbox.tsx | 37 ++++++++- .../chat/hooks/useAgentChatSession.types.ts | 2 +- src/lib/chatModels.test.ts | 65 ++++++++++++++++ src/lib/chatModels.ts | 74 ++++++++++++++++++ src/lib/chatStream.test.ts | 4 +- src/lib/chatStream.ts | 4 +- 7 files changed, 220 insertions(+), 41 deletions(-) create mode 100644 src/lib/chatModels.test.ts create mode 100644 src/lib/chatModels.ts diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index 40d4c33..d40a5c1 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -28,6 +28,7 @@ import BoltRounded from "@mui/icons-material/BoltRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded"; +import type { AgentModelOption } from "@/lib/chatModels"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; export type AgentComposerHandle = { @@ -48,12 +49,23 @@ type AgentComposerProps = { onAbort: () => void; onStartListening: () => void; onStopListening: () => void; - selectedModel: AgentModel; + modelOptions: AgentModelOption[]; + selectedModel?: AgentModel; onModelChange: (model: AgentModel) => void; approvalMode: AgentApprovalMode; onApprovalModeChange: (mode: AgentApprovalMode) => void; }; +const renderModelIcon = ( + icon: AgentModelOption["icon"] | undefined, + props?: React.ComponentProps<typeof BoltRounded>, +) => + icon === "bolt" ? ( + <BoltRounded {...props} /> + ) : ( + <AutoAwesomeRounded {...props} /> + ); + export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ isHydrating = false, isStreaming, @@ -64,6 +76,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer onAbort, onStartListening, onStopListening, + modelOptions, selectedModel, onModelChange, approvalMode, @@ -74,6 +87,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer const [input, setInput] = React.useState(""); const [isPresetOpen, setIsPresetOpen] = React.useState(false); const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; + const selectedModelOption = modelOptions.find((model) => model.id === selectedModel); React.useImperativeHandle( ref, @@ -347,19 +361,21 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer <Stack direction="row" spacing={1} alignItems="center"> <FormControl size="small" sx={{ minWidth: 80 }}> <Select - value={selectedModel} + value={selectedModel ?? ""} onChange={(event) => onModelChange(event.target.value as AgentModel)} - disabled={isHydrating || isStreaming} + disabled={isHydrating || isStreaming || modelOptions.length === 0} aria-label="模型选择" - renderValue={(val) => ( - <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> - {val === "deepseek/deepseek-v4-flash" ? ( - <BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} /> - ) : ( - <AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} /> - )} + renderValue={() => ( + <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> + {renderModelIcon(selectedModelOption?.icon, { + sx: { + fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16, + color: "inherit", + transition: "color 0.2s", + }, + })} <Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}> - {val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"} + {selectedModelOption?.label ?? "模型"} </Typography> </Box> )} @@ -433,30 +449,25 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer }} > <Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}> - <Box - component="img" - src="/deepseek-logo.svg" - alt="DeepSeek" - sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }} - /> + <AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} /> <Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}> - DEEPSEEK V4 + 模型选择 </Typography> </Box> - <MenuItem value="deepseek/deepseek-v4-flash"> - <BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} /> - <Box> - <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography> - <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography> - </Box> - </MenuItem> - <MenuItem value="deepseek/deepseek-v4-pro"> - <AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} /> - <Box> - <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>专家</Typography> - <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography> - </Box> - </MenuItem> + {modelOptions.map((model) => ( + <MenuItem key={model.id} value={model.id}> + {renderModelIcon(model.icon, { + className: "icon", + sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" }, + })} + <Box> + <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography> + {model.description ? ( + <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography> + ) : null} + </Box> + </MenuItem> + ))} </Select> </FormControl> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index a6b0e8d..e0bfd11 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -10,6 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material"; import { useNotification } from "@refinedev/core"; import { getAccessToken } from "@/lib/authToken"; +import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import { useProjectStore } from "@/store/projectStore"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; @@ -28,9 +29,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [isResizing, setIsResizing] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(false); - const [selectedModel, setSelectedModel] = useState<AgentModel>( - "deepseek/deepseek-v4-flash", - ); + const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]); + const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined); const [approvalMode, setApprovalMode] = useState<AgentApprovalMode>("request"); @@ -62,6 +62,36 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { isSupported: isSttSupported, } = useSpeechRecognition(handleSpeechResult); + useEffect(() => { + let cancelled = false; + + const loadModels = async () => { + try { + const modelConfig = await fetchAgentModels(); + if (cancelled) return; + setModelOptions(modelConfig.models); + setSelectedModel((current) => { + if (current && modelConfig.models.some((model) => model.id === current)) { + return current; + } + return modelConfig.defaultModel; + }); + } catch (error) { + console.error("[GlobalChatbox] Failed to load agent models:", error); + if (!cancelled) { + setModelOptions([]); + setSelectedModel(undefined); + } + } + }; + + void loadModels(); + + return () => { + cancelled = true; + }; + }, []); + const handleToolCall = useAgentToolActions(); const { messages, @@ -372,6 +402,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { onAbort={abort} onStartListening={startListening} onStopListening={stopListening} + modelOptions={modelOptions} selectedModel={selectedModel} onModelChange={setSelectedModel} approvalMode={approvalMode} diff --git a/src/components/chat/hooks/useAgentChatSession.types.ts b/src/components/chat/hooks/useAgentChatSession.types.ts index 5478f7d..68fef56 100644 --- a/src/components/chat/hooks/useAgentChatSession.types.ts +++ b/src/components/chat/hooks/useAgentChatSession.types.ts @@ -11,7 +11,7 @@ export type UseAgentChatSessionOptions = { }, ) => void; onBeforeSend?: () => void; - getModel?: () => AgentModel; + getModel?: () => AgentModel | undefined; getApprovalMode?: () => AgentApprovalMode; }; diff --git a/src/lib/chatModels.test.ts b/src/lib/chatModels.test.ts new file mode 100644 index 0000000..17b33e1 --- /dev/null +++ b/src/lib/chatModels.test.ts @@ -0,0 +1,65 @@ +import { fetchAgentModels } from "./chatModels"; + +const apiFetch = jest.fn(); + +jest.mock("@/lib/apiFetch", () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +describe("fetchAgentModels", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("loads model options and backend default model", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + default_model: "deepseek/deepseek-v4-flash", + models: [ + { + id: "deepseek/deepseek-v4-flash", + label: "快速", + description: "快速回答和任务执行", + icon: "bolt", + }, + ], + }), + }); + + await expect(fetchAgentModels()).resolves.toEqual({ + defaultModel: "deepseek/deepseek-v4-flash", + models: [ + { + id: "deepseek/deepseek-v4-flash", + label: "快速", + description: "快速回答和任务执行", + icon: "bolt", + }, + ], + }); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/models"), + expect.objectContaining({ + method: "GET", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }), + ); + }); + + it("falls back to the first option when default model is omitted", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [{ id: "provider/model", label: "Model" }], + }), + }); + + await expect(fetchAgentModels()).resolves.toEqual({ + defaultModel: "provider/model", + models: [{ id: "provider/model", label: "Model" }], + }); + }); +}); diff --git a/src/lib/chatModels.ts b/src/lib/chatModels.ts new file mode 100644 index 0000000..9c9166f --- /dev/null +++ b/src/lib/chatModels.ts @@ -0,0 +1,74 @@ +import { apiFetch } from "@/lib/apiFetch"; +import { config } from "@config/config"; + +import type { AgentModel } from "./chatStream"; + +export type AgentModelIcon = "bolt" | "sparkle"; + +export type AgentModelOption = { + id: AgentModel; + label: string; + description?: string; + icon?: AgentModelIcon; +}; + +export type AgentModelConfig = { + defaultModel?: AgentModel; + models: AgentModelOption[]; +}; + +const isObjectRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const normalizeModelOption = (value: unknown): AgentModelOption | null => { + if (!isObjectRecord(value) || typeof value.id !== "string") { + return null; + } + const id = value.id.trim(); + if (!id) { + return null; + } + const label = + typeof value.label === "string" && value.label.trim() + ? value.label.trim() + : id; + const description = + typeof value.description === "string" && value.description.trim() + ? value.description.trim() + : undefined; + const icon = + value.icon === "bolt" || value.icon === "sparkle" ? value.icon : undefined; + return { + id, + label, + description, + icon, + }; +}; + +export const fetchAgentModels = async (): Promise<AgentModelConfig> => { + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, { + method: "GET", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }); + if (!response.ok) { + throw new Error(await response.text()); + } + const payload = (await response.json()) as { + default_model?: unknown; + models?: unknown[]; + }; + const models = (payload.models ?? []) + .map(normalizeModelOption) + .filter((model): model is AgentModelOption => Boolean(model)); + const defaultModel = + typeof payload.default_model === "string" && payload.default_model.trim() + ? payload.default_model.trim() + : models[0]?.id; + return { + defaultModel, + models, + }; +}; diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index d4e32d7..56d2709 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -60,7 +60,7 @@ describe("streamAgentChat", () => { await streamAgentChat({ message: "hi", - model: "deepseek/deepseek-v4-flash", + model: "provider/model", onEvent: (event) => events.push(event), }); @@ -73,7 +73,7 @@ describe("streamAgentChat", () => { body: JSON.stringify({ message: "hi", session_id: undefined, - model: "deepseek/deepseek-v4-flash", + model: "provider/model", approval_mode: undefined, }), }), diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index f1fc494..7b763f8 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -1,9 +1,7 @@ import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; -export type AgentModel = - | "deepseek/deepseek-v4-flash" - | "deepseek/deepseek-v4-pro"; +export type AgentModel = string; export type PermissionReply = "once" | "always" | "reject"; export type AgentApprovalMode = "request" | "always"; -- 2.54.0 From 224d53a04d31a70f7b4d8253f47a4b065d36e6a8 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 21:12:53 +0800 Subject: [PATCH 189/281] feat(chat): smooth streaming output --- docs/chat-streaming-animation-notes.md | 167 +++++++++++++++ src/components/chat/AgentMarkdownBlock.tsx | 151 ++++++++++++- src/components/chat/AgentTurn.tsx | 130 +++++++++++- src/components/chat/AgentWorkspace.test.tsx | 20 +- src/components/chat/AgentWorkspace.tsx | 65 ++++-- src/components/chat/ChatInlineChart.tsx | 200 +++++++++++++++--- src/components/chat/GlobalChatbox.tsx | 64 +++++- .../chat/GlobalChatboxMarkdown.module.css | 5 + .../chat/hooks/useAgentChatSession.ts | 198 +++++++++++++++-- 9 files changed, 915 insertions(+), 85 deletions(-) create mode 100644 docs/chat-streaming-animation-notes.md diff --git a/docs/chat-streaming-animation-notes.md b/docs/chat-streaming-animation-notes.md new file mode 100644 index 0000000..f23d4d3 --- /dev/null +++ b/docs/chat-streaming-animation-notes.md @@ -0,0 +1,167 @@ +# Chat 流式生成动画改造经验 + +本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。 + +## 目标 + +- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。 +- 已生成内容必须稳定,不能反复淡入、重排或闪烁。 +- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。 +- 底部自动滚动要跟随,但不能每个 token 都强制贴底。 +- 动画应辅助理解,不能比内容本身更抢眼。 + +## 文本流式生成 + +### 经验结论 + +不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。 + +更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路: + +- token 先进入缓冲区。 +- 前端按固定节奏释放 chunk。 +- chunk 尽量按词、短语、标点边界切分。 +- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。 + +当前实现采用: + +- `TOKEN_PLAYBACK_INTERVAL_MS = 16` +- 小缓冲按较短 chunk 输出。 +- 大缓冲最多每帧释放 `160` 字符。 +- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`。 +- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。 + +### 踩坑 + +- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。 +- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。 +- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。 +- `done`、`error`、`tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。 + +## Markdown 动画 + +### 经验结论 + +Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。 + +典型例子: + +- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。 +- 列表符号、链接语法、代码块围栏都可能影响原始索引。 +- 新增文本可能落在 `p`、`li`、`strong`、`code` 等不同节点里。 + +因此不要简单用原始 `text.length` 或 `fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。 + +当前策略: + +- Markdown 仍完整解析,保证格式正确。 +- 在 rehype 阶段处理 AST。 +- 从 AST 尾部反向找最后的可见 text node。 +- 只给最后一段尾部文本加动画。 +- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。 + +### 踩坑 + +- 用 `text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。 +- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。 +- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。 +- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。 + +## 当前文字动画建议 + +推荐保留轻量动画: + +- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。 +- 使用 `clip-path` 做左到右 reveal。 +- 叠加轻微 opacity:当前约 `0.46 -> 1`。 +- 时长控制在 `120ms - 260ms`。 + +不要做: + +- 外层整段 `motion.div` 淡入。 +- 每次流式更新都改变 key。 +- 对整个 Markdown AST 的新增范围大面积包 span。 +- 在生成中对已有文本重复动画。 + +## 滚动和边框稳定 + +### 经验结论 + +滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight` 或 `scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。 + +当前策略: + +- 生成中不再每个 token 精确贴底。 +- 底部保留生成缓冲区,当前约 `180px`。 +- 只有缓冲被消耗到阈值后才恢复滚动。 +- 用户离开底部附近后,不再强制自动跟随。 +- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。 + +### 踩坑 + +- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。 +- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。 +- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。 + +## 图表生成 + +### 经验结论 + +图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。 + +当前策略: + +- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`。 +- 图表真实数据到达后,继续短暂保留 skeleton overlay。 +- ECharts 在 skeleton 下方淡入。 +- 容器尺寸保持一致,避免边框高度突变。 +- ECharts 内部使用 enter/update 动画,而不是外层布局动画。 + +图表类型动画建议: + +- 折线图:平滑 enter,面积渐显。 +- 柱状图:柱子从基线增长,并对数据点轻微 stagger。 +- 饼图:使用 expansion/sweep 类进入动画。 +- update 动画要短于 enter 动画。 + +### 踩坑 + +- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。 +- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。 +- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。 + +## 状态提示 + +“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。 + +推荐: + +- 状态放在“分析结果”标题行右侧。 +- 使用小尺寸、低干扰的 pulsing dots。 +- 不使用末尾光标,避免和业务文本混在一起。 + +## 验证建议 + +每次调整流式动画后至少跑: + +```bash +npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts +npx tsc --noEmit +npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand +``` + +人工验证重点: + +- 长中文回答是否明显落后后端真实速度。 +- Markdown 加粗、列表、代码块是否乱序。 +- 底部自动滚动时“分析结果”边框是否抖动。 +- 工具调用 pending 到图表出现时是否有高度跳变。 +- 用户手动上滚后是否停止强制跟随。 + +## 后续调整原则 + +1. 先调节 token playback,再调动画。 +2. 动画只作用于新增内容,已有内容不能重播。 +3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。 +4. 图表和工具调用先稳定布局,再考虑视觉效果。 +5. 滚动跟随要有缓冲,不能逐 token 贴底。 diff --git a/src/components/chat/AgentMarkdownBlock.tsx b/src/components/chat/AgentMarkdownBlock.tsx index b95c4fe..2acef39 100644 --- a/src/components/chat/AgentMarkdownBlock.tsx +++ b/src/components/chat/AgentMarkdownBlock.tsx @@ -1,14 +1,141 @@ "use client"; import React from "react"; -import ReactMarkdown from "react-markdown"; +import type { Element, Root, RootContent, Text } from "hast"; +import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); -export const MarkdownBlock = ({ children }: { children: string }) => { +const isTextNode = (node: RootContent): node is Text => node.type === "text"; + +const isElementNode = (node: RootContent): node is Element => node.type === "element"; + +const createFadeSpan = (value: string, fadeKey: string): Element => ({ + type: "element", + tagName: "span", + properties: { + className: [markdownStyles.streamFade], + dataStreamFadeKey: fadeKey, + dataStreamRevealLength: value.length, + }, + children: [{ type: "text", value }], +}); + +const splitTextTail = (value: string, tailLength: number) => { + const codePoints = Array.from(value); + const stableText = codePoints.slice(0, -tailLength).join(""); + const animatedText = codePoints.slice(-tailLength).join(""); + return { stableText, animatedText }; +}; + +const createStreamFadePlugin = (fadeLength: number, fadeKey: string) => { + return () => (tree: Root) => { + let remainingFadeLength = fadeLength; + + const visitChildren = (parent: Element | Root) => { + const nextChildren: RootContent[] = []; + + for (let index = parent.children.length - 1; index >= 0; index -= 1) { + const child = (parent.children as RootContent[])[index]; + if (isTextNode(child)) { + if (!child.value.trim()) { + nextChildren.unshift(child); + continue; + } + + if (remainingFadeLength <= 0) { + nextChildren.unshift(child); + continue; + } + + const textLength = Array.from(child.value).length; + const tailLength = Math.min(textLength, remainingFadeLength); + const { stableText, animatedText } = splitTextTail(child.value, tailLength); + remainingFadeLength -= tailLength; + + if (animatedText) { + nextChildren.unshift(createFadeSpan(animatedText, fadeKey)); + } + if (stableText) { + nextChildren.unshift({ ...child, value: stableText }); + } + continue; + } + + if (isElementNode(child)) { + visitChildren(child); + } + + nextChildren.unshift(child); + } + + parent.children = nextChildren as typeof parent.children; + }; + + visitChildren(tree); + }; +}; + +const StreamFadeSpan: Components["span"] = ({ node, children, ...props }) => { + const ref = React.useRef<HTMLSpanElement>(null); + const fadeKeyValue = node?.properties?.dataStreamFadeKey; + const fadeKey = typeof fadeKeyValue === "string" ? fadeKeyValue : undefined; + const revealLengthValue = node?.properties?.dataStreamRevealLength; + const revealLength = + typeof revealLengthValue === "number" + ? revealLengthValue + : typeof revealLengthValue === "string" + ? Number.parseInt(revealLengthValue, 10) + : 0; + + React.useLayoutEffect(() => { + if (!fadeKey) return; + + const element = ref.current; + if (!element) return; + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + + const duration = Math.min(260, Math.max(120, revealLength * 14)); + const animation = element.animate( + [ + { clipPath: "inset(0 100% 0 0)", opacity: 0.46 }, + { clipPath: "inset(0 0% 0 0)", opacity: 1 }, + ], + { + duration, + easing: "cubic-bezier(0.16, 1, 0.3, 1)", + fill: "both", + }, + ); + + return () => { + animation.cancel(); + }; + }, [fadeKey, revealLength]); + + return ( + <span {...props} ref={ref}> + {children} + </span> + ); +}; + +const markdownComponents: Components = { + span: StreamFadeSpan, +}; + +export const MarkdownBlock = ({ + children, + streamFadeKey, + streamFadeLength, +}: { + children: string; + streamFadeKey?: string; + streamFadeLength?: number | null; +}) => { const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => { const selectedText = window.getSelection()?.toString(); if (!selectedText) return; @@ -16,12 +143,26 @@ export const MarkdownBlock = ({ children }: { children: string }) => { event.preventDefault(); event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); }, []); + const rehypePlugins = React.useMemo( + () => + typeof streamFadeLength === "number" && streamFadeLength > 0 + ? [createStreamFadePlugin( + streamFadeLength, + streamFadeKey ?? `stream-tail-${children.length}`, + )] + : [], + [children.length, streamFadeKey, streamFadeLength], + ); return ( <div className={markdownStyles.markdown} onCopy={handleCopy}> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> + <ReactMarkdown + components={markdownComponents} + remarkPlugins={[remarkGfm]} + rehypePlugins={rehypePlugins} + > + {children} + </ReactMarkdown> </div> ); }; - - diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 2d69838..7c9fa7b 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -25,7 +25,7 @@ import { import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; -import { ChatInlineChart } from "./ChatInlineChart"; +import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; import { PermissionRequestGroup } from "./AgentPermissionRequests"; @@ -51,6 +51,105 @@ type AgentTurnProps = { onRejectQuestion: (requestId: string) => void; }; +const StreamingStatus = () => { + const theme = useTheme(); + + return ( + <Stack + direction="row" + spacing={0.75} + alignItems="center" + sx={{ + px: 1, + py: 0.35, + borderRadius: 999, + bgcolor: alpha(theme.palette.primary.main, 0.07), + color: "text.secondary", + }} + > + <Stack direction="row" spacing={0.35} alignItems="center"> + {[0, 1, 2].map((index) => ( + <motion.span + key={index} + animate={{ opacity: [0.28, 0.86, 0.28] }} + transition={{ + duration: 0.95, + repeat: Infinity, + delay: index * 0.14, + ease: "easeInOut", + }} + style={{ + width: 4, + height: 4, + borderRadius: "50%", + background: theme.palette.primary.main, + display: "block", + }} + /> + ))} + </Stack> + <Typography variant="caption" color="text.secondary" fontWeight={700}> + 正在生成 + </Typography> + </Stack> + ); +}; + +const StreamingMarkdownBlock = ({ + text, + isStreaming, + segmentKey, +}: { + text: string; + isStreaming: boolean; + segmentKey: string; +}) => { + const [streamTextState, setStreamTextState] = React.useState<{ + displayText: string; + animatedTailLength: number; + }>({ + displayText: text, + animatedTailLength: 0, + }); + + React.useLayoutEffect(() => { + setStreamTextState((current) => { + if (current.displayText === text && current.animatedTailLength === 0) { + return current; + } + + if (!isStreaming) { + return { + displayText: text, + animatedTailLength: 0, + }; + } + + if (current.displayText === text) { + return current; + } + + return { + displayText: text, + animatedTailLength: + text.length > current.displayText.length && + text.startsWith(current.displayText) + ? Math.min(48, text.length - current.displayText.length) + : 0, + }; + }); + }, [isStreaming, text]); + + return ( + <MarkdownBlock + streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`} + streamFadeLength={streamTextState.animatedTailLength} + > + {streamTextState.displayText} + </MarkdownBlock> + ); +}; + export const AgentTurn = React.memo( ({ message, @@ -69,6 +168,7 @@ export const AgentTurn = React.memo( const theme = useTheme(); const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); + const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const [isHovered, setIsHovered] = React.useState(false); const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", @@ -238,17 +338,28 @@ export const AgentTurn = React.memo( borderRadius: 4, bgcolor: alpha("#fff", 0.4), border: `1px solid ${alpha("#fff", 0.6)}`, + position: "relative", }} > <Stack spacing={1.2}> - <Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> - 分析结果 - </Typography> + <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}> + <Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> + 分析结果 + </Typography> + {isStreamingAssistant ? <StreamingStatus /> : null} + </Stack> {contentSegments.map((segment, segIdx) => { if (segment.type === "text") { const text = segment.content.trim(); if (!text && contentSegments.length > 1) return null; - return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>; + return ( + <StreamingMarkdownBlock + key={segIdx} + text={text || "..."} + isStreaming={isStreamingAssistant} + segmentKey={`${message.id}-${segIdx}`} + /> + ); } if (segment.type === "tool_call") { if ( @@ -267,6 +378,7 @@ export const AgentTurn = React.memo( series={p.series} x_axis_name={(p.x_axis_name as string) ?? undefined} y_axis_name={(p.y_axis_name as string) ?? undefined} + isStreaming={isStreamingAssistant} /> ); } @@ -279,9 +391,10 @@ export const AgentTurn = React.memo( } if (segment.type === "tool_call_pending") { return ( - <Typography key="tool-pending" variant="caption" color="text.secondary"> - 正在准备工具调用... - </Typography> + <ChartGenerationSkeleton + key="tool-pending" + status={<StreamingStatus />} + /> ); } return null; @@ -306,6 +419,7 @@ export const AgentTurn = React.memo( series={artifact.params.series} x_axis_name={(artifact.params.x_axis_name as string) ?? undefined} y_axis_name={(artifact.params.y_axis_name as string) ?? undefined} + isStreaming={isStreamingAssistant} /> ))} </Stack> diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index 237d903..a28aae5 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -7,6 +7,7 @@ import { AgentWorkspace } from "./AgentWorkspace"; import type { Message } from "./GlobalChatbox.types"; const renderCounts = new Map<string, number>(); +const streamingFlags = new Map<string, boolean>(); jest.mock("next/image", () => ({ __esModule: true, @@ -16,7 +17,18 @@ jest.mock("next/image", () => ({ jest.mock("framer-motion", () => ({ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, motion: { - div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>, + div: ({ + children, + animate: _animate, + exit: _exit, + initial: _initial, + layout: _layout, + transition: _transition, + whileHover: _whileHover, + ...props + }: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => ( + <div {...props}>{children}</div> + ), }, })); @@ -25,8 +37,9 @@ jest.mock("./GlobalChatbox.parts", () => ({ })); jest.mock("./AgentTurn", () => ({ - AgentTurn: ({ message }: { message: Message }) => { + AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => { renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1); + streamingFlags.set(message.id, isStreaming); return <div data-testid={`turn-${message.id}`}>{message.content}</div>; }, })); @@ -49,6 +62,7 @@ describe("AgentWorkspace", () => { beforeEach(() => { renderCounts.clear(); + streamingFlags.clear(); }); it("shows a loading skeleton instead of the empty state while switching history sessions", () => { @@ -106,5 +120,7 @@ describe("AgentWorkspace", () => { expect(renderCounts.get("user-1")).toBe(1); expect(renderCounts.get("assistant-1")).toBe(1); expect(renderCounts.get("assistant-2")).toBe(2); + expect(streamingFlags.get("assistant-1")).toBe(false); + expect(streamingFlags.get("assistant-2")).toBe(true); }); }); diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index e1ef132..0b0dab9 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -21,7 +21,9 @@ type AgentWorkspaceProps = { messages: Message[]; isStreaming: boolean; isLoadingSession?: boolean; + scrollContainerRef?: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>; + onScrollStateChange?: (isNearBottom: boolean) => void; speakingMessageId: string | null; speechState: SpeechState; onSpeak: (messageId: string, text: string) => void; @@ -51,6 +53,9 @@ type TurnListProps = { onRejectQuestion: (requestId: string) => void; }; +const STREAMING_BOTTOM_RESERVE_PX = 180; +const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120; + const sameMessages = (left: Message[], right: Message[]) => left.length === right.length && left.every((message, index) => message === right[index]); @@ -293,7 +298,9 @@ export const AgentWorkspace = ({ messages, isStreaming, isLoadingSession = false, + scrollContainerRef, bottomRef, + onScrollStateChange, speakingMessageId, speechState, onSpeak, @@ -321,9 +328,24 @@ export const AgentWorkspace = ({ : undefined; const historyMessages = streamingMessage !== undefined ? messages.slice(0, -1) : messages; + const handleScroll = React.useCallback( + (event: React.UIEvent<HTMLDivElement>) => { + if (!onScrollStateChange) return; + const target = event.currentTarget; + const distanceToBottom = + target.scrollHeight - target.scrollTop - target.clientHeight; + onScrollStateChange( + distanceToBottom < + (isStreaming ? STREAMING_NEAR_BOTTOM_THRESHOLD_PX : 96), + ); + }, + [isStreaming, onScrollStateChange], + ); return ( <Box + ref={scrollContainerRef} + onScroll={handleScroll} sx={{ flex: 1, overflowY: "auto", @@ -331,6 +353,7 @@ export const AgentWorkspace = ({ py: 2, display: "flex", flexDirection: "column", + scrollbarGutter: "stable", zIndex: 5, }} > @@ -346,7 +369,7 @@ export const AgentWorkspace = ({ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <TurnList messages={historyMessages} - isStreaming={isStreaming} + isStreaming={false} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={onSpeak} @@ -361,21 +384,23 @@ export const AgentWorkspace = ({ /> {streamingMessage ? ( - <TurnList - messages={[streamingMessage]} - isStreaming={isStreaming} - speakingMessageId={speakingMessageId} - speechState={speechState} - onSpeak={onSpeak} - onPauseSpeech={onPauseSpeech} - onResumeSpeech={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - onCreateBranch={onCreateBranch} - onReplyPermission={onReplyPermission} - onReplyQuestion={onReplyQuestion} - onRejectQuestion={onRejectQuestion} - /> + <Box sx={{ width: "100%" }}> + <TurnList + messages={[streamingMessage]} + isStreaming={isStreaming} + speakingMessageId={speakingMessageId} + speechState={speechState} + onSpeak={onSpeak} + onPauseSpeech={onPauseSpeech} + onResumeSpeech={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onCreateBranch={onCreateBranch} + onReplyPermission={onReplyPermission} + onReplyQuestion={onReplyQuestion} + onRejectQuestion={onRejectQuestion} + /> + </Box> ) : null} </Box> ) : null} @@ -403,7 +428,13 @@ export const AgentWorkspace = ({ </motion.div> ) : null} - <div ref={bottomRef} style={{ height: 1 }} /> + <div + ref={bottomRef} + style={{ + flexShrink: 0, + height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1, + }} + /> </Box> ); }; diff --git a/src/components/chat/ChatInlineChart.tsx b/src/components/chat/ChatInlineChart.tsx index 9715b14..65ef5c8 100644 --- a/src/components/chat/ChatInlineChart.tsx +++ b/src/components/chat/ChatInlineChart.tsx @@ -3,7 +3,8 @@ import React, { useMemo } from "react"; import ReactECharts from "echarts-for-react"; import * as echarts from "echarts"; -import { Box, Paper, Typography, alpha, useTheme } from "@mui/material"; +import { AnimatePresence, motion } from "framer-motion"; +import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme } from "@mui/material"; /* ------------------------------------------------------------------ */ /* Inline chart rendered inside a chat message bubble. */ @@ -47,8 +48,12 @@ export interface ChatInlineChartProps { series?: unknown; y_axis_name?: string; x_axis_name?: string; + isStreaming?: boolean; } +export const CHART_HEIGHT = 240; +export const CHART_MIN_HEIGHT = 286; + const COLORS = [ "#5470c6", "#91cc75", @@ -61,6 +66,49 @@ const COLORS = [ "#ea7ccc", ]; +const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => ( + <Stack spacing={1.25} sx={{ p: 1.5 }}> + <Stack direction="row" alignItems="center" justifyContent="space-between"> + <Skeleton variant="text" width="34%" height={20} /> + {status} + </Stack> + <Skeleton variant="rounded" height={208} sx={{ borderRadius: 2 }} /> + <Stack direction="row" spacing={1}> + <Skeleton variant="text" width="24%" height={16} /> + <Skeleton variant="text" width="18%" height={16} /> + <Skeleton variant="text" width="20%" height={16} /> + </Stack> + </Stack> +); + +export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => { + const theme = useTheme(); + + return ( + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.18 }} + style={{ width: "100%" }} + > + <Paper + elevation={0} + sx={{ + mt: 1.5, + mb: 1, + minHeight: CHART_MIN_HEIGHT, + borderRadius: 3, + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + bgcolor: alpha("#fff", 0.78), + overflow: "hidden", + }} + > + <ChartSkeletonContent status={status} /> + </Paper> + </motion.div> + ); +}; + const toFiniteNumber = (value: unknown): number | null => { if (typeof value === "number") { return Number.isFinite(value) ? value : null; @@ -189,13 +237,23 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ series, y_axis_name: yAxisName, x_axis_name: xAxisName, + isStreaming = false, }) => { const theme = useTheme(); + const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true); const { xData, series: chartSeries } = useMemo( () => normalizeChartData(x_data, series), [x_data, series], ); + React.useEffect(() => { + const timer = window.setTimeout(() => { + setShowIntroSkeleton(false); + }, isStreaming ? 360 : 260); + + return () => window.clearTimeout(timer); + }, [isStreaming]); + const option = useMemo(() => { if (!chartSeries.length) return null; @@ -208,6 +266,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ })) ?? []; return { + animation: true, + animationDuration: isStreaming ? 560 : 420, + animationDurationUpdate: 240, + animationEasing: "cubicOut", + animationEasingUpdate: "cubicOut", tooltip: { trigger: "item" }, legend: { top: "bottom", textStyle: { fontSize: 11 } }, series: [ @@ -223,6 +286,10 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ }, }, label: { fontSize: 11 }, + animationType: "expansion", + animationDuration: isStreaming ? 560 : 420, + animationDelay: (idx: number) => idx * 40, + animationDurationUpdate: 240, }, ], color: COLORS, @@ -231,6 +298,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ /* ---------- Line / Bar chart ---------- */ return { + animation: true, + animationDuration: isStreaming ? 560 : 420, + animationDurationUpdate: 240, + animationEasing: "cubicOut", + animationEasingUpdate: "cubicOut", tooltip: { trigger: "axis", confine: true }, legend: { top: "top", textStyle: { fontSize: 11 } }, grid: { @@ -262,14 +334,22 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ : undefined, series: chartSeries.map((s, i) => { const color = COLORS[i % COLORS.length]; + const isLineSeries = chartType === "line"; return { name: s.name, type: (s.type ?? chartType) as string, data: s.data, - symbol: chartType === "line" ? "none" : undefined, - smooth: chartType === "line", + symbol: isLineSeries ? "none" : undefined, + smooth: isLineSeries, itemStyle: { color }, - ...(chartType === "line" + animationDuration: isStreaming ? 560 : 420, + animationDurationUpdate: 240, + animationDelay: + chartType === "bar" + ? (idx: number) => i * 80 + idx * 18 + : i * 80, + animationDelayUpdate: 0, + ...(isLineSeries ? { areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ @@ -284,44 +364,96 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({ }), color: COLORS, }; - }, [chartType, xData, chartSeries, title, yAxisName, xAxisName]); + }, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]); if (!option) { return ( - <Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}> - 图表数据为空 - </Typography> + <Paper + elevation={0} + sx={{ + mt: 1.5, + mb: 1, + minHeight: 72, + display: "flex", + alignItems: "center", + px: 2, + borderRadius: 3, + border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, + bgcolor: alpha("#fff", 0.72), + }} + > + <Typography variant="caption" color="text.secondary"> + 图表数据为空 + </Typography> + </Paper> ); } return ( - <Paper - elevation={0} - sx={{ - mt: 1.5, - mb: 1, - borderRadius: 3, - border: `1px solid ${alpha(theme.palette.divider, 0.15)}`, - bgcolor: alpha("#fff", 0.92), - overflow: "hidden", - }} + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.22, ease: "easeOut" }} + style={{ width: "100%" }} > - {title && ( - <Typography - variant="subtitle2" - sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }} + <Paper + elevation={0} + sx={{ + mt: 1.5, + mb: 1, + minHeight: CHART_MIN_HEIGHT, + borderRadius: 3, + border: `1px solid ${alpha(theme.palette.divider, 0.15)}`, + bgcolor: alpha("#fff", 0.92), + overflow: "hidden", + position: "relative", + }} + > + <AnimatePresence initial={false}> + {showIntroSkeleton ? ( + <Box + key="chart-intro-skeleton" + component={motion.div} + aria-hidden + initial={{ opacity: 1 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={{ duration: 0.22, ease: "easeOut" }} + sx={{ + position: "absolute", + inset: 0, + zIndex: 2, + bgcolor: alpha("#fff", 0.92), + pointerEvents: "none", + }} + > + <ChartSkeletonContent /> + </Box> + ) : null} + </AnimatePresence> + {title && ( + <Typography + variant="subtitle2" + sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }} + > + {title} + </Typography> + )} + <Box + component={motion.div} + initial={{ opacity: 0 }} + animate={{ opacity: showIntroSkeleton ? 0.35 : 1 }} + transition={{ duration: 0.24, ease: "easeOut" }} + sx={{ px: 1, pb: 1, minHeight: CHART_HEIGHT }} > - {title} - </Typography> - )} - <Box sx={{ px: 1, pb: 1 }}> - <ReactECharts - option={option} - style={{ height: 240, width: "100%" }} - notMerge - lazyUpdate - /> - </Box> - </Paper> + <ReactECharts + option={option} + style={{ height: CHART_HEIGHT, width: "100%" }} + notMerge + lazyUpdate + /> + </Box> + </Paper> + </motion.div> ); }; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index e0bfd11..ce53b76 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -24,6 +24,9 @@ import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice" import { useAgentChatSession } from "./hooks/useAgentChatSession"; import { useAgentToolActions } from "./hooks/useAgentToolActions"; +const STREAMING_BOTTOM_RESERVE_PX = 180; +const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36; + export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); @@ -35,6 +38,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { useState<AgentApprovalMode>("request"); const bottomRef = useRef<HTMLDivElement>(null); + const workspaceScrollRef = useRef<HTMLDivElement>(null); + const isNearBottomRef = useRef(true); + const streamingScrollFrameRef = useRef<number | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null); const hasResetForOpenRef = useRef(false); const theme = useTheme(); @@ -123,9 +129,53 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { bottomRef.current?.scrollIntoView({ behavior }); }, []); + const cancelStreamingScroll = useCallback(() => { + if (streamingScrollFrameRef.current === null) return; + window.cancelAnimationFrame(streamingScrollFrameRef.current); + streamingScrollFrameRef.current = null; + }, []); + + const scheduleStreamingScrollToBottom = useCallback(() => { + if (streamingScrollFrameRef.current !== null) return; + streamingScrollFrameRef.current = window.requestAnimationFrame(() => { + streamingScrollFrameRef.current = null; + const container = workspaceScrollRef.current; + if (!container || !isNearBottomRef.current) return; + + const distanceToBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return; + + container.scrollTop = container.scrollHeight - container.clientHeight; + }); + }, []); + + const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => { + isNearBottomRef.current = isNearBottom; + }, []); + useEffect(() => { - scrollToBottom(isStreaming ? "auto" : "smooth"); - }, [isStreaming, messages, scrollToBottom]); + if (isStreaming) { + if (!isNearBottomRef.current) return; + scheduleStreamingScrollToBottom(); + return; + } + cancelStreamingScroll(); + scrollToBottom("smooth"); + }, [ + cancelStreamingScroll, + isStreaming, + messages, + scheduleStreamingScrollToBottom, + scrollToBottom, + ]); + + useEffect( + () => () => { + cancelStreamingScroll(); + }, + [cancelStreamingScroll], + ); useEffect(() => { if (!open) { @@ -140,10 +190,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { composerRef.current?.clear(); setIsHistoryOpen(false); composerRef.current?.focus(); + isNearBottomRef.current = true; + cancelStreamingScroll(); scrollToBottom("auto"); }, 0); return () => window.clearTimeout(timer); - }, [createSession, isHydrating, open, scrollToBottom]); + }, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]); const handleSend = useCallback(async (prompt: string) => { if (isStreaming || isCheckingAuth) return; @@ -181,9 +233,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { composerRef.current?.clear(); window.setTimeout(() => { composerRef.current?.focus(); + isNearBottomRef.current = true; + cancelStreamingScroll(); scrollToBottom("auto"); }, 0); - }, [createSession, handleStopSpeech, scrollToBottom, stopListening]); + }, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]); const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); @@ -377,7 +431,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { messages={messages} isStreaming={isStreaming} isLoadingSession={Boolean(loadingSessionId)} + scrollContainerRef={workspaceScrollRef} bottomRef={bottomRef} + onScrollStateChange={handleWorkspaceScrollStateChange} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={handleSpeak} diff --git a/src/components/chat/GlobalChatboxMarkdown.module.css b/src/components/chat/GlobalChatboxMarkdown.module.css index 3ffcc21..aa51fc7 100644 --- a/src/components/chat/GlobalChatboxMarkdown.module.css +++ b/src/components/chat/GlobalChatboxMarkdown.module.css @@ -115,3 +115,8 @@ color: var(--chat-md-quote-text); border-radius: 6px; } + +.streamFade { + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 0c5b9d9..de92b9d 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -10,6 +10,69 @@ import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSess import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; +const TOKEN_PLAYBACK_INTERVAL_MS = 16; +const TOKEN_PLAYBACK_BASE_CHARS = 28; +const TOKEN_PLAYBACK_MAX_CHARS = 160; + +const sliceCodePoints = (value: string, count: number) => + Array.from(value).slice(0, count).join(""); + +let cachedSegmenter: Intl.Segmenter | null | undefined; + +const getSegmenter = () => { + if (cachedSegmenter !== undefined) return cachedSegmenter; + cachedSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter("zh", { granularity: "word" }) + : null; + return cachedSegmenter; +}; + +const getPlaybackChunkSize = (bufferLength: number) => { + if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS; + if (bufferLength >= 300) return 112; + if (bufferLength >= 140) return 72; + if (bufferLength >= 64) return 44; + return TOKEN_PLAYBACK_BASE_CHARS; +}; + +const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => { + if (content.length <= maxChars) return content; + const targetChars = Math.max(12, Math.floor(maxChars * 0.68)); + + const segmenter = getSegmenter(); + if (segmenter) { + let chunk = ""; + for (const segment of segmenter.segment(content)) { + chunk += segment.segment; + if ( + chunk.length >= maxChars || + (chunk.length >= targetChars && + /[\s,。!?、;:,.!?;:]/u.test(segment.segment)) + ) { + return chunk; + } + } + } + + const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0]; + if (phrase) return phrase; + + const cjkChunk = content.match( + /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u, + )?.[0]; + if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18)); + + const wordChunk = content.match(/^\S+\s*/u)?.[0]; + if (wordChunk) { + return wordChunk.length <= maxChars + ? wordChunk + : sliceCodePoints(wordChunk, maxChars); + } + + return sliceCodePoints(content, Math.min(maxChars, 12)); +}; + export const useAgentChatSession = ({ projectId, onToolCall, @@ -34,6 +97,11 @@ export const useAgentChatSession = ({ const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef<Promise<void> | null>(null); const titleUpdateNonceRef = useRef(0); + const pendingTokenRef = useRef<{ + assistantMessageId: string; + content: string; + } | null>(null); + const tokenPlaybackIntervalRef = useRef<number | null>(null); useEffect(() => { sessionIdRef.current = sessionId; @@ -43,6 +111,99 @@ export const useAgentChatSession = ({ messagesRef.current = messages; }, [messages]); + const applyTokenContent = useCallback((assistantMessageId: string, content: string) => { + if (!content) return; + setMessages((prev) => { + const next = prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: message.content + content, + isError: false, + } + : message, + ); + messagesRef.current = next; + return next; + }); + }, []); + + const cancelTokenPlayback = useCallback(() => { + const intervalId = tokenPlaybackIntervalRef.current; + if (intervalId === null) return; + window.clearInterval(intervalId); + tokenPlaybackIntervalRef.current = null; + }, []); + + const flushPendingTokens = useCallback(() => { + const pending = pendingTokenRef.current; + pendingTokenRef.current = null; + cancelTokenPlayback(); + if (!pending) return; + applyTokenContent(pending.assistantMessageId, pending.content); + }, [applyTokenContent, cancelTokenPlayback]); + + const scheduleTokenPlayback = useCallback(() => { + if (tokenPlaybackIntervalRef.current !== null) return; + const id = window.setInterval(() => { + const pending = pendingTokenRef.current; + if (!pending) { + window.clearInterval(id); + tokenPlaybackIntervalRef.current = null; + return; + } + + const chunk = takeNextTokenPlaybackChunk( + pending.content, + getPlaybackChunkSize(pending.content.length), + ); + if (!chunk) { + window.clearInterval(id); + tokenPlaybackIntervalRef.current = null; + pendingTokenRef.current = null; + return; + } + + const remaining = pending.content.slice(chunk.length); + pendingTokenRef.current = remaining + ? { assistantMessageId: pending.assistantMessageId, content: remaining } + : null; + applyTokenContent(pending.assistantMessageId, chunk); + + if (!remaining) { + window.clearInterval(id); + tokenPlaybackIntervalRef.current = null; + } + }, TOKEN_PLAYBACK_INTERVAL_MS); + tokenPlaybackIntervalRef.current = id; + }, [applyTokenContent]); + + const queueTokenContent = useCallback( + (assistantMessageId: string, content: string) => { + const pending = pendingTokenRef.current; + if (pending && pending.assistantMessageId !== assistantMessageId) { + flushPendingTokens(); + } + pendingTokenRef.current = { + assistantMessageId, + content: + pending?.assistantMessageId === assistantMessageId + ? pending.content + content + : content, + }; + scheduleTokenPlayback(); + }, + [flushPendingTokens, scheduleTokenPlayback], + ); + + useEffect( + () => () => { + pendingTokenRef.current = null; + cancelTokenPlayback(); + }, + [cancelTokenPlayback], + ); + useEffect(() => { isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; @@ -135,6 +296,10 @@ export const useAgentChatSession = ({ assistantMessageId?: string; }, ) => { + if (event.type !== "token") { + flushPendingTokens(); + } + if ( event.type !== "session_title" && "sessionId" in event && @@ -187,17 +352,7 @@ export const useAgentChatSession = ({ } if (event.type === "token") { - setMessages((prev) => - prev.map((message) => - message.id === assistantMessageId - ? { - ...message, - content: message.content + event.content, - isError: false, - } - : message, - ), - ); + queueTokenContent(assistantMessageId, event.content); } else if (event.type === "progress") { setMessages((prev) => prev.map((message) => @@ -303,7 +458,13 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, getLastAssistantMessageId, onToolCall], + [ + appendArtifact, + flushPendingTokens, + getLastAssistantMessageId, + onToolCall, + queueTokenContent, + ], ); const resumeStreamingSession = useCallback( @@ -319,18 +480,20 @@ export const useAgentChatSession = ({ onEvent: (event) => applyStreamEvent(event), }) .catch((error) => { + flushPendingTokens(); if (!controller.signal.aborted) { console.error("[GlobalChatbox] Failed to resume chat stream:", error); setIsStreaming(false); } }) .finally(() => { + flushPendingTokens(); if (abortRef.current === controller) { abortRef.current = null; } }); }, - [applyStreamEvent], + [applyStreamEvent, flushPendingTokens], ); resumeStreamingSessionRef.current = resumeStreamingSession; @@ -379,6 +542,7 @@ export const useAgentChatSession = ({ }), }); } catch (error) { + flushPendingTokens(); if (controller.signal.aborted) { setMessages((prev) => prev @@ -415,12 +579,14 @@ export const useAgentChatSession = ({ ); setIsStreaming(false); } finally { + flushPendingTokens(); abortRef.current = null; setIsStreaming(false); } }, [ applyStreamEvent, + flushPendingTokens, getApprovalMode, getModel, isHydrating, @@ -433,6 +599,7 @@ export const useAgentChatSession = ({ const abort = useCallback(() => { const controller = abortRef.current; controller?.abort(); + flushPendingTokens(); setIsStreaming(false); const assistantMessageId = getLastAssistantMessageId(); @@ -455,7 +622,7 @@ export const useAgentChatSession = ({ } }); cancelPromiseRef.current = trackedCancelPromise; - }, [getLastAssistantMessageId]); + }, [flushPendingTokens, getLastAssistantMessageId]); const replyPermission = useCallback( async (requestId: string, reply: PermissionReply) => { @@ -668,6 +835,7 @@ export const useAgentChatSession = ({ const createSession = useCallback(() => { if (isHydrating || isStreaming) return; + flushPendingTokens(); const controller = abortRef.current; controller?.abort(); hydrationNonceRef.current += 1; @@ -678,7 +846,7 @@ export const useAgentChatSession = ({ setIsSessionTitleManuallyEdited(false); setSessionId(undefined); setIsStreaming(false); - }, [isHydrating, isStreaming]); + }, [flushPendingTokens, isHydrating, isStreaming]); const switchSession = useCallback( async (nextSessionId: string, optimisticTitle?: string) => { -- 2.54.0 From 4374c89a639020b03fb5f14a910b7922b1f18ce8 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 21:13:15 +0800 Subject: [PATCH 190/281] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.gitignore?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=AD=A3=E7=8E=AF=E5=A2=83=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=B9=B6=E6=B7=BB=E5=8A=A0=20docs/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 06961ee..affc947 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,7 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local - +.env.local # vercel .vercel @@ -35,3 +34,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts memery.md + +docs/ \ No newline at end of file -- 2.54.0 From a6ea97142aeb247257e341f9833205d4d10b26e5 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 21:17:24 +0800 Subject: [PATCH 191/281] fix(chat): avoid final stream remount --- src/components/chat/AgentTurn.tsx | 2 +- src/components/chat/AgentWorkspace.test.tsx | 45 +++++++++++++++++++++ src/components/chat/AgentWorkspace.tsx | 36 +++++------------ 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 7c9fa7b..4f2a1f8 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -114,7 +114,7 @@ const StreamingMarkdownBlock = ({ React.useLayoutEffect(() => { setStreamTextState((current) => { - if (current.displayText === text && current.animatedTailLength === 0) { + if (current.displayText === text) { return current; } diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index a28aae5..134d6ac 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -7,6 +7,8 @@ import { AgentWorkspace } from "./AgentWorkspace"; import type { Message } from "./GlobalChatbox.types"; const renderCounts = new Map<string, number>(); +const mountCounts = new Map<string, number>(); +const unmountCounts = new Map<string, number>(); const streamingFlags = new Map<string, boolean>(); jest.mock("next/image", () => ({ @@ -38,6 +40,12 @@ jest.mock("./GlobalChatbox.parts", () => ({ jest.mock("./AgentTurn", () => ({ AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => { + React.useEffect(() => { + mountCounts.set(message.id, (mountCounts.get(message.id) ?? 0) + 1); + return () => { + unmountCounts.set(message.id, (unmountCounts.get(message.id) ?? 0) + 1); + }; + }, [message.id]); renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1); streamingFlags.set(message.id, isStreaming); return <div data-testid={`turn-${message.id}`}>{message.content}</div>; @@ -62,6 +70,8 @@ describe("AgentWorkspace", () => { beforeEach(() => { renderCounts.clear(); + mountCounts.clear(); + unmountCounts.clear(); streamingFlags.clear(); }); @@ -123,4 +133,39 @@ describe("AgentWorkspace", () => { expect(streamingFlags.get("assistant-1")).toBe(false); expect(streamingFlags.get("assistant-2")).toBe(true); }); + + it("does not remount the streaming assistant turn when streaming finishes", () => { + const userMessage: Message = { + id: "user-1", + role: "user", + content: "question", + }; + const assistantMessage: Message = { + id: "assistant-1", + role: "assistant", + content: "final answer", + }; + + const { rerender } = render( + <AgentWorkspace + {...defaultProps} + isStreaming + messages={[userMessage, assistantMessage]} + />, + ); + + expect(streamingFlags.get("assistant-1")).toBe(true); + + rerender( + <AgentWorkspace + {...defaultProps} + isStreaming={false} + messages={[userMessage, assistantMessage]} + />, + ); + + expect(mountCounts.get("assistant-1")).toBe(1); + expect(unmountCounts.get("assistant-1") ?? 0).toBe(0); + expect(streamingFlags.get("assistant-1")).toBe(false); + }); }); diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 0b0dab9..00a63a0 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -40,6 +40,7 @@ type AgentWorkspaceProps = { type TurnListProps = { messages: Message[]; isStreaming: boolean; + streamingMessageId: string | null; speakingMessageId: string | null; speechState: SpeechState; onSpeak: (messageId: string, text: string) => void; @@ -60,9 +61,12 @@ const sameMessages = (left: Message[], right: Message[]) => left.length === right.length && left.every((message, index) => message === right[index]); +const TurnItem = React.memo(AgentTurn); + const TurnListInner = ({ messages, isStreaming, + streamingMessageId, speakingMessageId, speechState, onSpeak, @@ -78,10 +82,10 @@ const TurnListInner = ({ return ( <> {messages.map((message) => ( - <AgentTurn + <TurnItem key={message.id} message={message} - isStreaming={isStreaming} + isStreaming={isStreaming && message.id === streamingMessageId} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} onSpeak={onSpeak} onPause={onPauseSpeech} @@ -103,6 +107,7 @@ const TurnList = React.memo( (prevProps, nextProps) => sameMessages(prevProps.messages, nextProps.messages) && prevProps.isStreaming === nextProps.isStreaming && + prevProps.streamingMessageId === nextProps.streamingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speechState === nextProps.speechState && prevProps.onSpeak === nextProps.onSpeak && @@ -326,8 +331,6 @@ export const AgentWorkspace = ({ isStreaming && messages.at(-1)?.role === "assistant" ? messages.at(-1) : undefined; - const historyMessages = - streamingMessage !== undefined ? messages.slice(0, -1) : messages; const handleScroll = React.useCallback( (event: React.UIEvent<HTMLDivElement>) => { if (!onScrollStateChange) return; @@ -368,8 +371,9 @@ export const AgentWorkspace = ({ {messages.length > 0 ? ( <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <TurnList - messages={historyMessages} - isStreaming={false} + messages={messages} + isStreaming={isStreaming} + streamingMessageId={streamingMessage?.id ?? null} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={onSpeak} @@ -382,26 +386,6 @@ export const AgentWorkspace = ({ onReplyQuestion={onReplyQuestion} onRejectQuestion={onRejectQuestion} /> - - {streamingMessage ? ( - <Box sx={{ width: "100%" }}> - <TurnList - messages={[streamingMessage]} - isStreaming={isStreaming} - speakingMessageId={speakingMessageId} - speechState={speechState} - onSpeak={onSpeak} - onPauseSpeech={onPauseSpeech} - onResumeSpeech={onResumeSpeech} - onStopSpeech={onStopSpeech} - isTtsSupported={isTtsSupported} - onCreateBranch={onCreateBranch} - onReplyPermission={onReplyPermission} - onReplyQuestion={onReplyQuestion} - onRejectQuestion={onRejectQuestion} - /> - </Box> - ) : null} </Box> ) : null} </> -- 2.54.0 From 877b79ada8011f4464c225356d2c59bd54c0687b Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 10 Jun 2026 21:33:33 +0800 Subject: [PATCH 192/281] refactor(chat): remove typing indicator --- src/components/chat/AgentWorkspace.test.tsx | 4 -- src/components/chat/AgentWorkspace.tsx | 49 ++++----------------- src/components/chat/GlobalChatbox.parts.tsx | 30 ------------- 3 files changed, 9 insertions(+), 74 deletions(-) diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index 134d6ac..a135fdf 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -34,10 +34,6 @@ jest.mock("framer-motion", () => ({ }, })); -jest.mock("./GlobalChatbox.parts", () => ({ - TypingIndicator: () => <div>typing</div>, -})); - jest.mock("./AgentTurn", () => ({ AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => { React.useEffect(() => { diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 00a63a0..2833b7e 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -10,7 +10,6 @@ import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; import MapRounded from "@mui/icons-material/MapRounded"; import { AgentTurn } from "./AgentTurn"; -import { TypingIndicator } from "./GlobalChatbox.parts"; import type { PermissionReply } from "@/lib/chatStream"; import type { Message, @@ -39,7 +38,7 @@ type AgentWorkspaceProps = { type TurnListProps = { messages: Message[]; - isStreaming: boolean; + isAssistantStreaming: boolean; streamingMessageId: string | null; speakingMessageId: string | null; speechState: SpeechState; @@ -65,7 +64,7 @@ const TurnItem = React.memo(AgentTurn); const TurnListInner = ({ messages, - isStreaming, + isAssistantStreaming, streamingMessageId, speakingMessageId, speechState, @@ -85,7 +84,7 @@ const TurnListInner = ({ <TurnItem key={message.id} message={message} - isStreaming={isStreaming && message.id === streamingMessageId} + isStreaming={isAssistantStreaming && message.id === streamingMessageId} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} onSpeak={onSpeak} onPause={onPauseSpeech} @@ -106,7 +105,7 @@ const TurnList = React.memo( TurnListInner, (prevProps, nextProps) => sameMessages(prevProps.messages, nextProps.messages) && - prevProps.isStreaming === nextProps.isStreaming && + prevProps.isAssistantStreaming === nextProps.isAssistantStreaming && prevProps.streamingMessageId === nextProps.streamingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speechState === nextProps.speechState && @@ -318,19 +317,10 @@ export const AgentWorkspace = ({ onReplyQuestion, onRejectQuestion, }: AgentWorkspaceProps) => { - const theme = useTheme(); - const latestAssistant = [...messages] - .reverse() - .find((message) => message.role === "assistant"); - const showTypingIndicator = - isStreaming && - (!latestAssistant || - (latestAssistant.content.trim().length === 0 && - !(latestAssistant.artifacts?.length))); - const streamingMessage = + const streamingMessageId = isStreaming && messages.at(-1)?.role === "assistant" - ? messages.at(-1) - : undefined; + ? messages.at(-1)?.id ?? null + : null; const handleScroll = React.useCallback( (event: React.UIEvent<HTMLDivElement>) => { if (!onScrollStateChange) return; @@ -372,8 +362,8 @@ export const AgentWorkspace = ({ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <TurnList messages={messages} - isStreaming={isStreaming} - streamingMessageId={streamingMessage?.id ?? null} + isAssistantStreaming={isStreaming} + streamingMessageId={streamingMessageId} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={onSpeak} @@ -391,27 +381,6 @@ export const AgentWorkspace = ({ </> )} - {!isLoadingSession && showTypingIndicator ? ( - <motion.div - initial={{ opacity: 0, y: 10, scale: 0.94 }} - animate={{ opacity: 1, y: 0, scale: 1 }} - transition={{ type: "spring", stiffness: 300 }} - style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }} - > - <Paper - elevation={0} - sx={{ - p: 1.3, - borderRadius: 4, - bgcolor: alpha("#fff", 0.82), - boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`, - }} - > - <TypingIndicator /> - </Paper> - </motion.div> - ) : null} - <div ref={bottomRef} style={{ diff --git a/src/components/chat/GlobalChatbox.parts.tsx b/src/components/chat/GlobalChatbox.parts.tsx index 780f839..38b55a0 100644 --- a/src/components/chat/GlobalChatbox.parts.tsx +++ b/src/components/chat/GlobalChatbox.parts.tsx @@ -2,36 +2,6 @@ import React from "react"; import { motion } from "framer-motion"; -import { Box, Stack } from "@mui/material"; - -export const TypingIndicator = () => { - return ( - <Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}> - {[0, 1, 2].map((i) => ( - <motion.div - key={i} - initial={{ y: 0 }} - animate={{ y: [-4, 4, -4] }} - transition={{ - duration: 0.6, - repeat: Infinity, - delay: i * 0.15, - ease: "easeInOut", - }} - > - <Box - sx={{ - width: 8, - height: 8, - borderRadius: "50%", - background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)", - }} - /> - </motion.div> - ))} - </Stack> - ); -}; export const Blob = ({ color, -- 2.54.0 From bb7311589c1ac8619f3969267b28c58fff876ef7 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 10:18:41 +0800 Subject: [PATCH 193/281] refactor(auth): remove agent user header --- src/components/chat/chatStorage.ts | 4 --- .../chat/hooks/useAgentChatSession.ts | 15 +++++++++++ .../core/Controls/useToolbarChatActions.ts | 1 - src/lib/authToken.ts | 27 ------------------- src/lib/chatModels.test.ts | 1 - src/lib/chatModels.ts | 1 - src/lib/chatStream.ts | 21 ++++++++++----- src/lib/requestHeaders.ts | 11 +------- 8 files changed, 30 insertions(+), 51 deletions(-) diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index dc50618..675bb0a 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -49,7 +49,6 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { method: "GET", projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }); if (!response.ok) { @@ -77,7 +76,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta { method: "GET", projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -123,7 +121,6 @@ const updateBackendChatSessionTitle = async ( is_title_manually_edited: isTitleManuallyEdited, }), projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -138,7 +135,6 @@ const deleteBackendChatSession = async (sessionId: string) => { { method: "DELETE", projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index de92b9d..dbc45c5 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -456,6 +456,21 @@ export const useAgentChatSession = ({ ), ); setIsStreaming(false); + } else if (event.type === "auth_required") { + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: message.content || `⚠️ **${event.message}**`, + isError: true, + progress: completeRunningProgress(message.progress), + todos: cancelRunningTodos(message.todos), + } + : message, + ), + ); + setIsStreaming(false); } }, [ diff --git a/src/components/olmap/core/Controls/useToolbarChatActions.ts b/src/components/olmap/core/Controls/useToolbarChatActions.ts index 7e472ea..e9d8ee7 100644 --- a/src/components/olmap/core/Controls/useToolbarChatActions.ts +++ b/src/components/olmap/core/Controls/useToolbarChatActions.ts @@ -168,7 +168,6 @@ export const useToolbarChatActions = ({ { method: "GET", projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); diff --git a/src/lib/authToken.ts b/src/lib/authToken.ts index 72bbff5..ee0f15c 100644 --- a/src/lib/authToken.ts +++ b/src/lib/authToken.ts @@ -49,30 +49,3 @@ export const getAccessToken = async () => { } return null; }; - -export const getUserId = async () => { - const session = await getSession(); - const sessionUserId = typeof session?.user?.id === "string" ? session.user.id : null; - if (sessionUserId) { - return sessionUserId; - } - - const accessToken = await getAccessToken(); - if (!accessToken) { - return null; - } - - const payload = decodeJwtPayload(accessToken); - if (!payload || typeof payload !== "object") { - return null; - } - - const candidate = - typeof payload.sub === "string" - ? payload.sub - : typeof payload.user_id === "string" - ? payload.user_id - : null; - - return candidate; -}; diff --git a/src/lib/chatModels.test.ts b/src/lib/chatModels.test.ts index 17b33e1..5de9324 100644 --- a/src/lib/chatModels.test.ts +++ b/src/lib/chatModels.test.ts @@ -43,7 +43,6 @@ describe("fetchAgentModels", () => { expect.objectContaining({ method: "GET", projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }), ); diff --git a/src/lib/chatModels.ts b/src/lib/chatModels.ts index 9c9166f..8db7a0a 100644 --- a/src/lib/chatModels.ts +++ b/src/lib/chatModels.ts @@ -50,7 +50,6 @@ export const fetchAgentModels = async (): Promise<AgentModelConfig> => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, { method: "GET", projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }); if (!response.ok) { diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 7b763f8..dea6560 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -84,6 +84,12 @@ export type StreamEvent = detail?: string; totalDurationMs?: number; } + | { + type: "auth_required"; + sessionId?: string; + reason?: string; + message: string; + } | { type: "tool_call"; sessionId: string; @@ -303,6 +309,7 @@ const emitParsedStreamEvent = ( rejected?: boolean; message_id?: string; todos?: unknown; + reason?: string; }; if (event === "state") { onEvent({ @@ -352,6 +359,13 @@ const emitParsedStreamEvent = ( detail: parsed.detail, totalDurationMs: parsed.total_duration_ms, }); + } else if (event === "auth_required") { + onEvent({ + type: "auth_required", + sessionId: parsed.session_id, + reason: parsed.reason, + message: parsed.message ?? "登录态已过期,请刷新登录后重试", + }); } else if (event === "tool_call") { onEvent({ type: "tool_call", @@ -478,7 +492,6 @@ export const streamAgentChat = async ({ approval_mode: approvalMode, }), projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -530,7 +543,6 @@ export const resumeAgentChatStream = async ({ Accept: "text/event-stream", }, projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -573,7 +585,6 @@ export const abortAgentChat = async (sessionId?: string) => { session_id: sessionId, }), projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }); @@ -600,7 +611,6 @@ export const replyAgentPermission = async ( reply, }), projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -628,7 +638,6 @@ export const replyAgentQuestion = async ( answers, }), projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -654,7 +663,6 @@ export const rejectAgentQuestion = async ( session_id: sessionId, }), projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }, ); @@ -676,7 +684,6 @@ export const forkAgentChat = async (sessionId: string | undefined, keepMessageCo keep_message_count: keepMessageCount, }), projectHeaderMode: "include", - userHeaderMode: "include", skipAuthRedirect: true, }); diff --git a/src/lib/requestHeaders.ts b/src/lib/requestHeaders.ts index 541ce4d..95e45ac 100644 --- a/src/lib/requestHeaders.ts +++ b/src/lib/requestHeaders.ts @@ -1,14 +1,12 @@ -import { getAccessToken, getUserId } from "@/lib/authToken"; +import { getAccessToken } from "@/lib/authToken"; import { useProjectStore } from "@/store/projectStore"; export type AuthHeaderMode = "include" | "omit"; export type ProjectHeaderMode = "auto" | "include" | "omit"; -export type UserHeaderMode = "include" | "omit"; export interface AuthContextHeaderOptions { authHeaderMode?: AuthHeaderMode; projectHeaderMode?: ProjectHeaderMode; - userHeaderMode?: UserHeaderMode; } const shouldIncludeProjectHeader = ( @@ -36,13 +34,6 @@ export const applyAuthContextHeaders = async ( headers.set("Authorization", `Bearer ${accessToken}`); } - if (options.userHeaderMode === "include") { - const userId = await getUserId(); - if (userId) { - headers.set("X-User-Id", userId); - } - } - const projectId = useProjectStore.getState().currentProjectId; if ( projectId && -- 2.54.0 From 757eea49de11fe2cc9b6d939553a4525f944529b Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 12:42:19 +0800 Subject: [PATCH 194/281] fix(layout): hide default dashboard --- src/app/(main)/layout.tsx | 2 ++ src/app/layout.tsx | 5 ++++- src/components/sider/AppSider.tsx | 17 ++++++++++++++++ src/providers/devtools/index.tsx | 33 ++++++++++++++++++++++--------- 4 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 src/components/sider/AppSider.tsx diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 08719db..07f97ed 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -6,6 +6,7 @@ import authOptions from "@app/api/auth/[...nextauth]/options"; import { Header } from "@components/header"; import { Title } from "@components/title"; import { MapSkeleton } from "@components/loading/MapSkeleton"; +import { AppSider } from "@components/sider/AppSider"; import { ThemedLayout } from "@refinedev/mui"; import { getServerSession } from "next-auth/next"; import { redirect } from "next/navigation"; @@ -35,6 +36,7 @@ export default async function MainLayout({ <ThemedLayout Header={Header} Title={Title} + Sider={AppSider} childrenBoxProps={{ sx: { height: "100vh", p: 0 }, }} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 292a454..937b644 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { cookies } from "next/headers"; import React, { Suspense } from "react"; import { RefineContext } from "./_refine_context"; import { META_DATA } from "@config/config"; +import { DevtoolsProvider } from "@providers/devtools"; export const metadata: Metadata = META_DATA; @@ -19,7 +20,9 @@ export default async function RootLayout({ <html lang="en"> <body> <Suspense> - <RefineContext defaultMode={defaultMode}>{children}</RefineContext> + <DevtoolsProvider> + <RefineContext defaultMode={defaultMode}>{children}</RefineContext> + </DevtoolsProvider> </Suspense> </body> </html> diff --git a/src/components/sider/AppSider.tsx b/src/components/sider/AppSider.tsx new file mode 100644 index 0000000..ec92420 --- /dev/null +++ b/src/components/sider/AppSider.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ThemedSider, type RefineThemedLayoutSiderProps } from "@refinedev/mui"; + +export const AppSider: React.FC<RefineThemedLayoutSiderProps> = (props) => { + return ( + <ThemedSider + {...props} + render={({ items, logout }) => ( + <> + {items} + {logout} + </> + )} + /> + ); +}; diff --git a/src/providers/devtools/index.tsx b/src/providers/devtools/index.tsx index e039bf9..01c4c8d 100644 --- a/src/providers/devtools/index.tsx +++ b/src/providers/devtools/index.tsx @@ -1,16 +1,31 @@ "use client"; -import { - DevtoolsPanel, - DevtoolsProvider as DevtoolsProviderBase, -} from "@refinedev/devtools"; -import React from "react"; +import React, { Suspense } from "react"; + +const RefineDevtools = React.lazy(async () => { + const { DevtoolsPanel, DevtoolsProvider: DevtoolsProviderBase } = + await import("@refinedev/devtools"); + + return { + default: (props: React.PropsWithChildren) => ( + <DevtoolsProviderBase> + {props.children} + <DevtoolsPanel /> + </DevtoolsProviderBase> + ), + }; +}); export const DevtoolsProvider = (props: React.PropsWithChildren) => { + if (process.env.NODE_ENV !== "development") { + return <>{props.children}</>; + } + return ( - <DevtoolsProviderBase> - {props.children} - <DevtoolsPanel /> - </DevtoolsProviderBase> + <Suspense fallback={props.children}> + <RefineDevtools> + {props.children} + </RefineDevtools> + </Suspense> ); }; -- 2.54.0 From 7f07f0449dc8bd0ed1b618e55fd5fecbd75a91a1 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 12:43:10 +0800 Subject: [PATCH 195/281] fix(devtools): set local devtools URL --- src/providers/devtools/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/devtools/index.tsx b/src/providers/devtools/index.tsx index 01c4c8d..54e2bbd 100644 --- a/src/providers/devtools/index.tsx +++ b/src/providers/devtools/index.tsx @@ -8,7 +8,7 @@ const RefineDevtools = React.lazy(async () => { return { default: (props: React.PropsWithChildren) => ( - <DevtoolsProviderBase> + <DevtoolsProviderBase url={["http://localhost:5001", "ws://localhost:5001"]}> {props.children} <DevtoolsPanel /> </DevtoolsProviderBase> -- 2.54.0 From 24cddc18a61c80d1ebb5fdb5593295ebdee5f990 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 12:54:25 +0800 Subject: [PATCH 196/281] chore(devtools): remove Refine devtools --- package-lock.json | 317 ------------------------------- package.json | 5 - src/app/layout.tsx | 5 +- src/providers/devtools/index.tsx | 31 --- 4 files changed, 1 insertion(+), 357 deletions(-) delete mode 100644 src/providers/devtools/index.tsx diff --git a/package-lock.json b/package-lock.json index ae9fa19..1ebde77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,11 +47,6 @@ }, "devDependencies": { "@refinedev/cli": "^2.16.52", - "@refinedev/devtools": "^2.0.5", - "@refinedev/devtools-internal": "^2.0.2", - "@refinedev/devtools-server": "^2.0.2", - "@refinedev/devtools-shared": "^2.0.2", - "@refinedev/devtools-ui": "^2.0.3", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -82,54 +77,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@aliemir/dom-to-fiber-utils": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@aliemir/dom-to-fiber-utils/-/dom-to-fiber-utils-0.4.1.tgz", - "integrity": "sha512-mWjCp9Uu3B1Rbtdnk23Coak831zgy+/1oS+FCTVhCAozGPUURE8IfVFQsCblZMWuT5j+QSc6JNDQ+l2JcZclzA==", - "dev": true, - "dependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0", - "react-reconciler": "^0.29.0" - } - }, - "node_modules/@aliemir/dom-to-fiber-utils/node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@aliemir/dom-to-fiber-utils/node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/@aliemir/dom-to-fiber-utils/node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2959,20 +2906,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@fireworks-js/react": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/@fireworks-js/react/-/react-2.10.8.tgz", - "integrity": "sha512-Qy/HSiTnph2IT2LHVzo9Ov+zifGQboTICjVLyrp/sWMHZoaodZiW0xrFxN1RfYI3R7j5bNZmf6S9EzqOWPfFWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fireworks-js": "2.10.8" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -3011,24 +2944,6 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, - "node_modules/@headlessui/react": { - "version": "1.7.19", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", - "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/react-virtual": "^3.0.0-beta.60", - "client-only": "^0.0.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16 || ^17 || ^18", - "react-dom": "^16 || ^17 || ^18" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -6081,32 +5996,6 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@refinedev/devtools": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@refinedev/devtools/-/devtools-2.0.5.tgz", - "integrity": "sha512-D3jQlB3VZ+evRI2wmy+0TMwY78mRR3EwECNzWpkETeg8rK/Ovjo4t6FMUKAke7qht3Zdlkc6zGv+V/vWK3IMfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@aliemir/dom-to-fiber-utils": "^0.4.0", - "@refinedev/devtools-shared": "2.0.2", - "error-stack-parser": "^2.1.4", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@refinedev/cli": "2.16.52", - "@refinedev/core": "^5.0.0", - "@refinedev/devtools-server": "2.0.2", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, "node_modules/@refinedev/devtools-internal": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@refinedev/devtools-internal/-/devtools-internal-2.0.2.tgz", @@ -6190,47 +6079,6 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@refinedev/devtools-ui": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@refinedev/devtools-ui/-/devtools-ui-2.0.3.tgz", - "integrity": "sha512-jxedCR6/LjdfifNl6D/QQseWH4cAHMJJ8BQKCLPnbxjIjnHgvRc0wr7pIpXLrpaFZumBCA4YvV1/xVw4z2sA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@fireworks-js/react": "^2.10.7", - "@headlessui/react": "^1.7.17", - "@refinedev/devtools-shared": "2.0.2", - "@tanstack/react-table": "^8.2.6", - "clsx": "^1.1.1", - "dayjs": "^1.10.7", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "prism-react-renderer": "^1.3.5", - "react-hook-form": "^7.57.0", - "react-json-view-lite": "^1.3.0", - "react-router": "^7.0.2", - "semver-diff": "^3.1.1" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@refinedev/devtools-ui/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@refinedev/kbar": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@refinedev/kbar/-/kbar-2.0.1.tgz", @@ -7768,70 +7616,6 @@ "react": "^18 || ^19" } }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.13.23", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", - "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.13.23" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.13.23", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", - "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -14858,13 +14642,6 @@ "node": ">=8" } }, - "node_modules/fireworks-js": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/fireworks-js/-/fireworks-js-2.10.8.tgz", - "integrity": "sha512-UZNxeJvRmQzLisN4iriWXqKojG9TDJqc0dPmkUw0/+AEQQ3w8z1Jx2YdFSiBGSVb/u4dPTQXU109GMVblzhfpg==", - "dev": true, - "license": "MIT" - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -21333,16 +21110,6 @@ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "license": "MIT" }, - "node_modules/prism-react-renderer": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.3.5.tgz", - "integrity": "sha512-IJ+MSwBWKG+SM3b2SUfdrhC+gu01QkV2KmRQgREThBfSQRoufqRfxfHUxpG1WcaFjP+kojcFyO9Qqtpgt3qLCg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": ">=0.14.9" - } - }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", @@ -21645,19 +21412,6 @@ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, - "node_modules/react-json-view-lite": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-1.5.0.tgz", - "integrity": "sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -21685,70 +21439,6 @@ "react": ">=18" } }, - "node_modules/react-reconciler": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", - "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-reconciler/node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/react-router": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", - "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -22560,13 +22250,6 @@ "node": ">= 0.8.0" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "dev": true, - "license": "MIT" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index 8460d87..2cc60ae 100644 --- a/package.json +++ b/package.json @@ -59,11 +59,6 @@ }, "devDependencies": { "@refinedev/cli": "^2.16.52", - "@refinedev/devtools": "^2.0.5", - "@refinedev/devtools-internal": "^2.0.2", - "@refinedev/devtools-server": "^2.0.2", - "@refinedev/devtools-shared": "^2.0.2", - "@refinedev/devtools-ui": "^2.0.3", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 937b644..292a454 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,6 @@ import { cookies } from "next/headers"; import React, { Suspense } from "react"; import { RefineContext } from "./_refine_context"; import { META_DATA } from "@config/config"; -import { DevtoolsProvider } from "@providers/devtools"; export const metadata: Metadata = META_DATA; @@ -20,9 +19,7 @@ export default async function RootLayout({ <html lang="en"> <body> <Suspense> - <DevtoolsProvider> - <RefineContext defaultMode={defaultMode}>{children}</RefineContext> - </DevtoolsProvider> + <RefineContext defaultMode={defaultMode}>{children}</RefineContext> </Suspense> </body> </html> diff --git a/src/providers/devtools/index.tsx b/src/providers/devtools/index.tsx deleted file mode 100644 index 54e2bbd..0000000 --- a/src/providers/devtools/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import React, { Suspense } from "react"; - -const RefineDevtools = React.lazy(async () => { - const { DevtoolsPanel, DevtoolsProvider: DevtoolsProviderBase } = - await import("@refinedev/devtools"); - - return { - default: (props: React.PropsWithChildren) => ( - <DevtoolsProviderBase url={["http://localhost:5001", "ws://localhost:5001"]}> - {props.children} - <DevtoolsPanel /> - </DevtoolsProviderBase> - ), - }; -}); - -export const DevtoolsProvider = (props: React.PropsWithChildren) => { - if (process.env.NODE_ENV !== "development") { - return <>{props.children}</>; - } - - return ( - <Suspense fallback={props.children}> - <RefineDevtools> - {props.children} - </RefineDevtools> - </Suspense> - ); -}; -- 2.54.0 From 8934844bd9101ffebd0f60b868b598ff3a766b0f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 12:58:41 +0800 Subject: [PATCH 197/281] chore(build): remove Refine CLI --- package-lock.json | 3072 +-------------------------------------------- package.json | 11 +- 2 files changed, 5 insertions(+), 3078 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ebde77..49713fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,6 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@refinedev/cli": "^2.16.52", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -676,22 +675,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", - "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", @@ -1197,23 +1180,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", - "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-flow": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", @@ -1982,24 +1948,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/preset-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", - "integrity": "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-flow-strip-types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -2056,26 +2004,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/register": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.28.6.tgz", - "integrity": "sha512-pgcbbEl/dWQYb6L6Yew6F94rdwygfuv+vJ/tXfwIOYAfPB6TNWpXUMEtEq3YuTeHRdvMIhvz13bkT9CNaS+wqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "find-cache-dir": "^2.0.0", - "make-dir": "^2.1.0", - "pirates": "^4.0.6", - "source-map-support": "^0.5.16" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -2169,17 +2097,6 @@ "yarn": ">=1.3.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -3462,45 +3379,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", - "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.0", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5609,72 +5487,6 @@ "node": ">=12.4.0" } }, - "node_modules/@npmcli/git": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", - "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^7.0.0", - "ini": "^4.1.3", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^9.0.0", - "proc-log": "^4.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/package-json": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.1.tgz", - "integrity": "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^7.0.0", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "proc-log": "^4.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", - "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -5918,57 +5730,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/@refinedev/cli": { - "version": "2.16.52", - "resolved": "https://registry.npmjs.org/@refinedev/cli/-/cli-2.16.52.tgz", - "integrity": "sha512-rr8QvL5ngpwqoD89unr1walLxGRHwQRC+bs9DYfPXiQM5oS8GNqo5GSQr70m1yZZssD1cNrIPvCuO67pkJxiYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@npmcli/package-json": "^5.2.0", - "@refinedev/devtools-server": "2.0.2", - "boxen": "^5.1.2", - "camelcase": "^6.2.0", - "cardinal": "^2.1.1", - "center-align": "1.0.1", - "chalk": "^4.1.2", - "cli-table3": "^0.6.3", - "commander": "9.4.1", - "conf": "^10.2.0", - "decamelize": "^5.0.0", - "dedent": "^0.7.0", - "dotenv": "^16.0.3", - "envinfo": "^7.8.1", - "execa": "^5.1.1", - "figlet": "^1.5.2", - "fs-extra": "^10.1.0", - "globby": "^11.1.0", - "gray-matter": "^4.0.3", - "handlebars": "^4.7.7", - "inquirer": "^8.2.5", - "inquirer-autocomplete-prompt": "^2.0.0", - "jscodeshift": "^17.3.0", - "marked": "^4.3.0", - "marked-terminal": "^6.0.0", - "node-emoji": "^2.1.3", - "node-env-type": "^0.0.8", - "node-fetch": "^2.6.7", - "ora": "^5.4.1", - "pluralize": "^8.0.0", - "preferred-pm": "^3.1.3", - "prettier": "^2.7.1", - "semver": "7.5.2", - "semver-diff": "^3.1.1", - "temp": "^0.9.4", - "tslib": "^2.6.2" - }, - "bin": { - "refine": "dist/cli.cjs" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@refinedev/core": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz", @@ -6016,50 +5777,6 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@refinedev/devtools-server": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@refinedev/devtools-server/-/devtools-server-2.0.2.tgz", - "integrity": "sha512-xiS9ROvwws/iqDykGps4hliPROYkgJMTZTHEt4llwJ7rlXxRpXyF316n2wWZUakWSBXRYxgIkIvdDeAE7Uu1Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@refinedev/devtools-shared": "2.0.2", - "body-parser": "^1.20.2", - "boxen": "^5.1.2", - "chalk": "^4.1.2", - "dedent": "^0.7.0", - "dotenv": "^16.0.3", - "error-stack-parser": "^2.1.4", - "execa": "^5.1.1", - "express": "^4.21.0", - "fs-extra": "^10.1.0", - "globby": "^11.1.0", - "gray-matter": "^4.0.3", - "http-proxy-middleware": "^3.0.0", - "jscodeshift": "^17.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "marked": "^4.3.0", - "node-fetch": "^2.6.7", - "package-manager-detector": "^0.1.1", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "sanitize-html": "^2.11.0", - "ws": "^8.13.0" - }, - "bin": { - "refine-devtools": "dist/cli.cjs" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, "node_modules/@refinedev/devtools-shared": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.2.tgz", @@ -7016,19 +6733,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -9948,16 +9652,6 @@ "@types/unist": "*" } }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -10738,20 +10432,6 @@ "win32" ] }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -10785,66 +10465,6 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/align-text": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-1.0.2.tgz", - "integrity": "sha512-uBPDs72zrRTdiTBY0YjBbuBOdXtRyT4qsKPb4bL4O7vH4utz/7KjwTJVsVbdThxMbVzkRGAfk8Ml3xoMvXSEYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^5.0.2", - "longest": "^2.0.1", - "repeat-string": "^1.6.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -10900,13 +10520,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansicolors": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", - "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", - "dev": true, - "license": "MIT" - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -10955,13 +10568,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -10985,16 +10591,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -11115,19 +10711,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -11151,16 +10734,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -11411,7 +10984,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "devOptional": true, "funding": [ { "type": "github", @@ -11426,7 +10998,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", @@ -11446,75 +11019,6 @@ "node": "*" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -11522,29 +11026,6 @@ "dev": true, "license": "ISC" }, - "node_modules/boxen": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", - "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.0", - "camelcase": "^6.2.0", - "chalk": "^4.1.0", - "cli-boxes": "^2.2.1", - "string-width": "^4.2.2", - "type-fest": "^0.20.2", - "widest-line": "^3.1.0", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -11645,31 +11126,6 @@ "node": ">=0.10.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -11677,16 +11133,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -11777,20 +11223,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/cardinal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", - "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansicolors": "~0.3.2", - "redeyed": "~2.1.0" - }, - "bin": { - "cdl": "bin/cdl.js" - } - }, "node_modules/cartocolor": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/cartocolor/-/cartocolor-5.0.2.tgz", @@ -11810,20 +11242,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/center-align": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-1.0.1.tgz", - "integrity": "sha512-j6Ba1Vwtu0i9CUfM5VicnMqOsNRMYnNAoTUTB/EzUFhBKkqFPD5UE2WTCSIy49OnbjTEnJ0t2CFPYMbKNrUi/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "align-text": "^1.0.0", - "repeat-string": "^1.6.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11891,13 +11309,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chardet": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", - "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", - "dev": true, - "license": "MIT" - }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -11939,71 +11350,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cli-boxes": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", - "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -12025,54 +11371,6 @@ "node": ">=12" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/clone-deep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clone-deep/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -12159,23 +11457,6 @@ "integrity": "sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==", "license": "MIT" }, - "node_modules/commander": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", - "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -12195,54 +11476,6 @@ "tinyqueue": "^2.0.3" } }, - "node_modules/conf": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", - "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.6.3", - "ajv-formats": "^2.1.1", - "atomically": "^1.7.0", - "debounce-fn": "^4.0.0", - "dot-prop": "^6.0.1", - "env-paths": "^2.2.1", - "json-schema-typed": "^7.0.3", - "onetime": "^5.1.2", - "pkg-up": "^3.1.0", - "semver": "^7.3.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -12258,13 +11491,6 @@ "node": ">= 0.6" } }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "dev": true, - "license": "MIT" - }, "node_modules/core-assert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", @@ -12780,22 +12006,6 @@ "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "license": "MIT" }, - "node_modules/debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -12813,19 +12023,6 @@ } } }, - "node_modules/decamelize": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", - "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -12895,13 +12092,6 @@ "node": ">=0.10" } }, - "node_modules/dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true, - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -12931,19 +12121,6 @@ "node": ">=0.10.0" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -13004,16 +12181,6 @@ "node": ">=0.4.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -13023,17 +12190,6 @@ "node": ">=6" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -13066,19 +12222,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -13179,35 +12322,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -13271,13 +12385,6 @@ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.227", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", @@ -13305,23 +12412,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emojilib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", - "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -13348,36 +12438,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/envinfo": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.15.0.tgz", - "integrity": "sha512-chR+t7exF6y59kelhXw5I3849nTy7KIRO+ePdLMhCD+JRP/JvmkenDWP7QSFGlsHX+kxGxdDutOPrmj5j1HR6g==", - "dev": true, - "license": "MIT", - "bin": { - "envinfo": "dist/cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -13579,13 +12639,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -14097,23 +13150,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -14166,89 +13202,12 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -14305,23 +13264,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -14401,58 +13343,6 @@ "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", "license": "MIT" }, - "node_modules/figlet": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.9.3.tgz", - "integrity": "sha512-majPgOpVtrZN1iyNGbsUP6bOtZ6eaJgg5HHh0vFvm5DJhh8dc+FJpOC4GABvMZ/A7XHAJUuJujhgUY/2jPWgMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^14.0.0" - }, - "bin": { - "figlet": "bin/index.js" - }, - "engines": { - "node": ">= 17.0.0" - } - }, - "node_modules/figlet/node_modules/commander": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", - "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -14488,57 +13378,6 @@ "node": ">=0.10.0" } }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/find-cache-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", - "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^2.0.0", - "pkg-dir": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -14562,86 +13401,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-yarn-workspace-root2": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root2/-/find-yarn-workspace-root2-1.2.16.tgz", - "integrity": "sha512-hr6hb1w8ePMpPVUK39S4RlwJzi+xPLuVuG8XlwXU3KD5Yn3qgBWVfy3AzNlDhWvE1EORCE65/Qm26rFQt3VLVA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "micromatch": "^4.0.2", - "pkg-dir": "^4.2.0" - } - }, - "node_modules/find-yarn-workspace-root2/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-yarn-workspace-root2/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-yarn-workspace-root2/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-yarn-workspace-root2/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-yarn-workspace-root2/node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -14663,16 +13422,6 @@ "dev": true, "license": "ISC" }, - "node_modules/flow-parser": { - "version": "0.308.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.308.0.tgz", - "integrity": "sha512-GYy1GfA6UeXM8gIlQQ4FDuZAcE+uzvA2JViWUT8S3aMRwgOZKoaA3Mt++2pJ1P7BfxG9UYhKzuuQsunh/hCu1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -14755,16 +13504,6 @@ "node": ">= 6" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/framer-motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", @@ -14792,31 +13531,6 @@ } } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -15144,27 +13858,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -15183,56 +13876,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/gray-matter/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/h3-js": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.3.0.tgz", @@ -15439,19 +14082,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -15482,75 +14112,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -15565,24 +14126,6 @@ "node": ">= 14" } }, - "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.15", - "debug": "^4.3.6", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.3", - "is-plain-object": "^5.0.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -15607,19 +14150,6 @@ "node": ">=10.17.0" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -15811,84 +14341,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", - "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, - "node_modules/inquirer": { - "version": "8.2.7", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", - "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/external-editor": "^1.0.0", - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/inquirer-autocomplete-prompt": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-2.0.1.tgz", - "integrity": "sha512-jUHrH0btO7j5r8DTQgANf2CBkTZChoVySD8zF/wp5fZCOLIuUbleXhf4ZY5jNBOc1owA3gdfWtfZuppfYBhcUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "ansi-escapes": "^4.3.2", - "figures": "^3.2.0", - "picocolors": "^1.0.0", - "run-async": "^2.4.1", - "rxjs": "^7.5.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "inquirer": "^8.0.0" - } - }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -15913,16 +14371,6 @@ "node": ">=12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -16132,16 +14580,6 @@ "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", "license": "MIT" }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -16230,16 +14668,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -16293,16 +14721,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -16315,16 +14733,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -16444,19 +14852,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -16509,26 +14904,6 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, - "node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -17701,47 +16076,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jscodeshift": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.3.0.tgz", - "integrity": "sha512-LjFrGOIORqXBU+jwfC9nbkjmQfFldtMIoS6d9z2LG/lkmyNXsJAySPT+2SWXJEoE68/bCWcxKpXH37npftgmow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/plugin-transform-class-properties": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/preset-flow": "^7.24.7", - "@babel/preset-typescript": "^7.24.7", - "@babel/register": "^7.24.6", - "flow-parser": "0.*", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.7", - "neo-async": "^2.5.0", - "picocolors": "^1.0.1", - "recast": "^0.23.11", - "tmp": "^0.2.3", - "write-file-atomic": "^5.0.1" - }, - "bin": { - "jscodeshift": "bin/jscodeshift.js" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@babel/preset-env": "^7.1.6" - }, - "peerDependenciesMeta": { - "@babel/preset-env": { - "optional": true - } - } - }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -17847,30 +16181,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", - "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -17891,19 +16201,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsts": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/jsts/-/jsts-2.7.1.tgz", @@ -17968,16 +16265,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ktx-parse": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", @@ -18277,46 +16564,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/load-yaml-file": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/load-yaml-file/-/load-yaml-file-0.2.0.tgz", - "integrity": "sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.5", - "js-yaml": "^3.13.0", - "pify": "^4.0.1", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/load-yaml-file/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/load-yaml-file/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -18366,23 +16613,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/long": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", @@ -18392,16 +16622,6 @@ "node": ">=0.6" } }, - "node_modules/longest": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", - "integrity": "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -18473,30 +16693,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -18530,66 +16726,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/marked": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", - "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/marked-terminal": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-6.2.0.tgz", - "integrity": "sha512-ubWhwcBFHnXsjYNsu+Wndpg0zhY4CahSpPlA70PlO0rR9r2sZpkyU+rkCsOWH+KMEkx847UpALON+HWgxowFtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^6.2.0", - "cardinal": "^2.1.1", - "chalk": "^5.3.0", - "cli-table3": "^0.6.3", - "node-emoji": "^2.1.3", - "supports-hyperlinks": "^3.0.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "marked": ">=1 <12" - } - }, - "node_modules/marked-terminal/node_modules/ansi-escapes": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", - "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/marked-terminal/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -18963,32 +17099,12 @@ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", "license": "MIT" }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -19006,16 +17122,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -19593,19 +17699,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -19627,16 +17720,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -19697,19 +17780,6 @@ "integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==", "license": "MIT" }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -19752,13 +17822,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true, - "license": "ISC" - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -19800,16 +17863,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -19941,53 +17994,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-emoji": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", - "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.6.0", - "char-regex": "^1.0.2", - "emojilib": "^2.4.0", - "skin-tone": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/node-env-type": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/node-env-type/-/node-env-type-0.0.8.tgz", - "integrity": "sha512-EXyxUOlwkuoMm6QHgX3zw06tY6NNpXm3Akf9n1zPUX8UD08FTXBQ9gcS0EWbuSPYqtxMneP72QgdPlLFrn2SpA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -20002,21 +18008,6 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -20065,61 +18056,6 @@ "node": ">=6" } }, - "node_modules/npm-install-checks": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", - "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-package-arg": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", - "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm-pick-manifest": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz", - "integrity": "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^11.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -20348,19 +18284,6 @@ "quickselect": "^3.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -20448,30 +18371,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -20539,13 +18438,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/package-manager-detector": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.1.2.tgz", - "integrity": "sha512-iePyefLTOm2gEzbaZKSW+eBMjg+UYsQvUKxmvGXAQ987K16efBg10MxIjZs08iyX+DY2/owKY9DIdu193kX33w==", - "dev": true, - "license": "MIT" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -20625,13 +18517,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, - "node_modules/parse-srcset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", - "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", - "dev": true, - "license": "MIT" - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -20658,16 +18543,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -20736,13 +18611,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "dev": true, - "license": "MIT" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -20784,16 +18652,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -20804,164 +18662,6 @@ "node": ">= 6" } }, - "node_modules/pkg-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", - "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-dir/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -21062,22 +18762,6 @@ "preact": ">=10" } }, - "node_modules/preferred-pm": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/preferred-pm/-/preferred-pm-3.1.4.tgz", - "integrity": "sha512-lEHd+yEm22jXdCphDrkvIJQU66EuLojPPtvZkpKIkiD+l0DMThF/niqZKJSoU8Vl7iuvtmzyMhir9LdVy5WMnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^5.0.0", - "find-yarn-workspace-root2": "1.2.16", - "path-exists": "^4.0.0", - "which-pm": "^2.2.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -21088,65 +18772,18 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/pretty-format": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "license": "MIT" }, - "node_modules/proc-log": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", - "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -21180,20 +18817,6 @@ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -21311,32 +18934,6 @@ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "license": "ISC" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/rbush": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", @@ -21508,33 +19105,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/recast/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -21549,16 +19119,6 @@ "node": ">=8" } }, - "node_modules/redeyed": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", - "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esprima": "~4.0.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -21746,23 +19306,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -21840,30 +19383,6 @@ "protocol-buffers-schema": "^3.3.1" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -21875,42 +19394,6 @@ "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/robust-predicates": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", @@ -21924,16 +19407,6 @@ "dev": true, "license": "MIT" }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -21958,16 +19431,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -21995,27 +19458,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -22065,21 +19507,6 @@ "dev": true, "license": "MIT" }, - "node_modules/sanitize-html": { - "version": "2.17.2", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.2.tgz", - "integrity": "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "deepmerge": "^4.2.2", - "escape-string-regexp": "^4.0.0", - "htmlparser2": "^10.1.0", - "is-plain-object": "^5.0.0", - "parse-srcset": "^1.0.2", - "postcss": "^8.3.11" - } - }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -22109,147 +19536,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/section-matter/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/semver": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", - "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-diff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", - "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/semver-diff/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -22305,36 +19591,6 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shallow-clone/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -22501,19 +19757,6 @@ "integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==", "license": "ISC" }, - "node_modules/skin-tone": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", - "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "unicode-emoji-modifier-base": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/skmeans": { "version": "0.9.7", "resolved": "https://registry.npmjs.org/skmeans/-/skmeans-0.9.7.tgz", @@ -22565,27 +19808,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -22596,42 +19818,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/splaytree-ts": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz", @@ -22689,16 +19875,6 @@ "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -22960,16 +20136,6 @@ "node": ">=4" } }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -23078,23 +20244,6 @@ "node": ">=8" } }, - "node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=14.18" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -23217,20 +20366,6 @@ "node": ">=18" } }, - "node_modules/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -23290,13 +20425,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -23359,16 +20487,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -23389,16 +20507,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/topojson-client": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", @@ -23450,13 +20558,6 @@ "node": ">=16" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -23624,33 +20725,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -23816,16 +20890,6 @@ "node": ">=4" } }, - "node_modules/unicode-emoji-modifier-base": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", - "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", @@ -23967,26 +21031,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -24078,16 +21122,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -24119,37 +21153,6 @@ "dev": true, "license": "MIT" }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/validate-npm-package-name": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", - "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -24207,29 +21210,12 @@ "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", "license": "MIT" }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/wgsl_reflect": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", @@ -24273,33 +21259,6 @@ "node": ">=18" } }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", @@ -24374,20 +21333,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-pm": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-2.2.0.tgz", - "integrity": "sha512-MOiaDbA5ZZgUjkeMWM5EkJp4loW5ZRoa5bc3/aeMox/PJelMhE6t7S/mLuiY43DBupyxH+S0U1bTui9kWUlmsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-yaml-file": "^0.2.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8.15" - } - }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -24410,19 +21355,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/widest-line": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", - "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 2cc60ae..8789533 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,13 @@ "node": ">=20" }, "scripts": { - "dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev", - "build": "refine build", - "start": "refine start", + "dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev", + "build": "next build", + "start": "next start", "lint": "eslint .", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "refine": "refine", "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh" }, "dependencies": { @@ -58,7 +57,6 @@ "fast-xml-parser": "5.5.9" }, "devDependencies": { - "@refinedev/cli": "^2.16.52", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -77,8 +75,5 @@ "jest-environment-jsdom": "^30.2.0", "ts-jest": "^29.4.6", "typescript": "^5.8.3" - }, - "refine": { - "projectId": "4LwOCL-BBaV29-qUYMAJ" } } -- 2.54.0 From 181871e0cff1e83155d1a9e7df8a47fee0d406f1 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 13:43:48 +0800 Subject: [PATCH 198/281] =?UTF-8?q?feat(*):=20=E6=B7=BB=E5=8A=A0=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/system-admin/page.tsx | 5 + src/app/_refine_context.tsx | 47 +- src/components/admin/SystemAdminPanel.tsx | 936 ++++++++++++++++++++++ 3 files changed, 987 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/system-admin/page.tsx create mode 100644 src/components/admin/SystemAdminPanel.tsx diff --git a/src/app/(main)/system-admin/page.tsx b/src/app/(main)/system-admin/page.tsx new file mode 100644 index 0000000..29f663e --- /dev/null +++ b/src/app/(main)/system-admin/page.tsx @@ -0,0 +1,5 @@ +import { SystemAdminPanel } from "@/components/admin/SystemAdminPanel"; + +export default function SystemAdminPage() { + return <SystemAdminPanel />; +} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index e78741d..29511e2 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -8,7 +8,7 @@ import { } from "@refinedev/mui"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import routerProvider from "@refinedev/nextjs-router"; @@ -16,6 +16,8 @@ import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; import { ProjectProvider } from "@/contexts/ProjectContext"; import { useAuthStore } from "@/store/authStore"; +import { apiFetch } from "@/lib/apiFetch"; +import { config } from "@config/config"; import { LiaNetworkWiredSolid } from "react-icons/lia"; import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; @@ -23,6 +25,7 @@ import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { + ManageAccounts as ManageAccountsIcon, MyLocation as MyLocationIcon, Search as SearchIcon, } from "@mui/icons-material"; @@ -51,11 +54,41 @@ const App = (props: React.PropsWithChildren<AppProps>) => { const { data, status } = useSession(); const to = usePathname(); const setAccessToken = useAuthStore((state) => state.setAccessToken); + const [isMetadataAdmin, setIsMetadataAdmin] = useState(false); useEffect(() => { setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null); }, [data?.accessToken, setAccessToken]); + useEffect(() => { + if (status !== "authenticated") { + setIsMetadataAdmin(false); + return; + } + + let cancelled = false; + apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { + projectHeaderMode: "omit", + skipAuthRedirect: true, + }) + .then(async (response) => { + if (cancelled) return; + if (!response.ok) { + setIsMetadataAdmin(false); + return; + } + const payload = await response.json(); + setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin")); + }) + .catch(() => { + if (!cancelled) setIsMetadataAdmin(false); + }); + + return () => { + cancelled = true; + }; + }, [status]); + if (status === "loading") { return <span>loading...</span>; } @@ -227,6 +260,18 @@ const App = (props: React.PropsWithChildren<AppProps>) => { label: "管道冲洗", }, }, + ...(isMetadataAdmin + ? [ + { + name: "系统管理", + list: "/system-admin", + meta: { + icon: <ManageAccountsIcon className="w-6 h-6" />, + label: "系统管理", + }, + }, + ] + : []), ]} options={{ syncWithLocation: true, diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx new file mode 100644 index 0000000..306b22c --- /dev/null +++ b/src/components/admin/SystemAdminPanel.tsx @@ -0,0 +1,936 @@ +"use client"; + +import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + alpha, + Box, + Button, + Chip, + CircularProgress, + Divider, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tooltip, + Typography, +} from "@mui/material"; +import { + Add as AddIcon, + AdminPanelSettings as AdminPanelSettingsIcon, + Block as BlockIcon, + CheckCircle as CheckCircleIcon, + Groups as GroupsIcon, + People as PeopleIcon, + Refresh as RefreshIcon, + RemoveCircleOutline as RemoveCircleOutlineIcon, + Security as SecurityIcon, +} from "@mui/icons-material"; +import { config } from "@config/config"; +import { apiFetch } from "@/lib/apiFetch"; +import { useProjectStore } from "@/store/projectStore"; + +type MetadataUser = { + id: string; + keycloak_id: string; + username: string; + email: string; + role: string; + is_active: boolean; + is_superuser: boolean; +}; + +type ProjectMember = { + id: string; + user_id: string; + project_id: string; + project_role: string; + username: string; + email: string; + is_active: boolean; +}; + +const businessRoleOptions = [ + { value: "admin", label: "系统管理员" }, + { value: "operator", label: "运行人员" }, + { value: "user", label: "普通用户" }, + { value: "viewer", label: "只读用户" }, +]; + +const projectRoleLabels: Record<string, string> = { + owner: "项目负责人", + admin: "项目管理员", + member: "项目成员", + viewer: "只读成员", +}; + +const projectRoleOptions = [ + { value: "admin", label: projectRoleLabels.admin }, + { value: "member", label: projectRoleLabels.member }, + { value: "viewer", label: projectRoleLabels.viewer }, +]; + +const getBusinessRoleLabel = (role: string) => + businessRoleOptions.find((option) => option.value === role)?.label ?? role; + +const getProjectRoleLabel = (role: string) => + projectRoleLabels[role] ?? role; + +const cardSx = { + borderRadius: 2, + borderColor: "divider", + boxShadow: "0 10px 30px rgba(15, 23, 42, 0.06)", +}; + +const tableSx = { + minWidth: 720, + "& .MuiTableCell-head": { + bgcolor: "action.hover", + color: "text.secondary", + fontSize: 12, + fontWeight: 700, + letterSpacing: 0, + }, + "& .MuiTableRow-root:last-child .MuiTableCell-body": { + borderBottom: 0, + }, +}; + +type StatCardProps = { + icon: React.ReactNode; + label: string; + value: string | number; + tone?: "primary" | "success" | "warning" | "info"; +}; + +const StatCard = ({ icon, label, value, tone = "primary" }: StatCardProps) => ( + <Paper + variant="outlined" + sx={(theme) => ({ + ...cardSx, + p: 2, + flex: "1 1 180px", + minWidth: 0, + bgcolor: alpha(theme.palette[tone].main, 0.04), + })} + > + <Stack direction="row" spacing={1.5} alignItems="center"> + <Box + sx={(theme) => ({ + width: 40, + height: 40, + borderRadius: 1.5, + display: "grid", + placeItems: "center", + color: `${tone}.main`, + bgcolor: alpha(theme.palette[tone].main, 0.12), + })} + > + {icon} + </Box> + <Box sx={{ minWidth: 0 }}> + <Typography variant="body2" color="text.secondary" noWrap> + {label} + </Typography> + <Typography variant="h6" fontWeight={800} lineHeight={1.2}> + {value} + </Typography> + </Box> + </Stack> + </Paper> +); + +const SectionHeader = ({ + title, + description, + action, +}: { + title: string; + description?: string; + action?: React.ReactNode; +}) => ( + <Stack + direction={{ xs: "column", sm: "row" }} + spacing={1.5} + alignItems={{ xs: "stretch", sm: "center" }} + justifyContent="space-between" + > + <Box> + <Typography variant="subtitle1" fontWeight={800}> + {title} + </Typography> + {description && ( + <Typography variant="body2" color="text.secondary"> + {description} + </Typography> + )} + </Box> + {action} + </Stack> +); + +const StatusChip = ({ active }: { active: boolean }) => ( + <Chip + size="small" + color={active ? "success" : "default"} + icon={active ? <CheckCircleIcon /> : <BlockIcon />} + label={active ? "启用" : "禁用"} + variant={active ? "filled" : "outlined"} + sx={{ minWidth: 76, justifyContent: "flex-start" }} + /> +); + +const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => ( + <TableRow> + <TableCell colSpan={colSpan} sx={{ py: 6 }}> + <Typography align="center" color="text.secondary"> + {label} + </Typography> + </TableCell> + </TableRow> +); + +export const SystemAdminPanel = () => { + const currentProjectId = useProjectStore((state) => state.currentProjectId); + const [tab, setTab] = useState(0); + const [users, setUsers] = useState<MetadataUser[]>([]); + const [members, setMembers] = useState<ProjectMember[]>([]); + const [currentAdmin, setCurrentAdmin] = useState<MetadataUser | null>(null); + const [adminChecked, setAdminChecked] = useState(false); + const [isAuthorized, setIsAuthorized] = useState(false); + const [projectId, setProjectId] = useState(currentProjectId ?? ""); + const [hasLoadedCurrentProjectMembers, setHasLoadedCurrentProjectMembers] = + useState(false); + const [message, setMessage] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + const [memberForm, setMemberForm] = useState({ + user_id: "", + project_role: "viewer", + }); + + const activeUsers = useMemo( + () => users.filter((user) => user.is_active).length, + [users], + ); + const systemAdminUsers = useMemo( + () => users.filter((user) => user.role === "admin").length, + [users], + ); + const superUsers = useMemo( + () => users.filter((user) => user.is_superuser).length, + [users], + ); + const memberUserIds = useMemo( + () => new Set(members.map((member) => member.user_id)), + [members], + ); + const availableMemberUsers = useMemo( + () => + users.filter( + (user) => + user.is_active && + !user.is_superuser && + user.id !== currentAdmin?.id && + !memberUserIds.has(user.id), + ), + [currentAdmin?.id, memberUserIds, users], + ); + const selectedMemberUser = useMemo( + () => users.find((user) => user.id === memberForm.user_id), + [memberForm.user_id, users], + ); + const hasProjectId = Boolean(projectId.trim()); + + const loadUsers = useCallback(async () => { + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`); + if (!response.ok) { + throw new Error(await response.text()); + } + setUsers(await response.json()); + }, []); + + const loadMembers = useCallback(async () => { + if (!projectId.trim()) return; + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/members`, + ); + if (!response.ok) { + throw new Error(await response.text()); + } + setMembers(await response.json()); + }, [projectId]); + + useEffect(() => { + let cancelled = false; + + const loadInitialState = async () => { + try { + const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { + projectHeaderMode: "omit", + skipAuthRedirect: true, + }); + if (!adminResponse.ok) { + if (!cancelled) { + setIsAuthorized(false); + setCurrentAdmin(null); + setAdminChecked(true); + } + return; + } + const adminPayload = await adminResponse.json(); + if (!cancelled) { + setCurrentAdmin(adminPayload); + setIsAuthorized(true); + setAdminChecked(true); + } + + const usersResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`); + if (!usersResponse.ok) { + throw new Error(await usersResponse.text()); + } + const payload = await usersResponse.json(); + if (!cancelled) setUsers(payload); + } catch (err) { + if (!cancelled) { + setAdminChecked(true); + setError(String(err)); + } + } + }; + + void loadInitialState(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!currentProjectId || projectId.trim()) return; + setProjectId(currentProjectId); + }, [currentProjectId, projectId]); + + useEffect(() => { + if (!isAuthorized || !hasProjectId || hasLoadedCurrentProjectMembers) return; + + setHasLoadedCurrentProjectMembers(true); + loadMembers().catch((err) => setError(String(err))); + }, [hasLoadedCurrentProjectMembers, hasProjectId, isAuthorized, loadMembers]); + + const updateUserActive = async (user: MetadataUser, isActive: boolean) => { + setError(null); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/users/${user.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_active: isActive }), + }, + ); + if (!response.ok) { + setError(await response.text()); + return; + } + await loadUsers(); + }; + + const updateUserRole = async (user: MetadataUser, role: string) => { + setError(null); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/users/${user.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }, + ); + if (!response.ok) { + setError(await response.text()); + return; + } + await loadUsers(); + }; + + const addMember = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setMessage(null); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/members`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(memberForm), + }, + ); + if (!response.ok) { + setError(await response.text()); + return; + } + setMessage("项目成员已添加"); + setMemberForm((prev) => ({ ...prev, user_id: "" })); + await loadMembers(); + }; + + const updateMemberRole = async (member: ProjectMember, projectRole: string) => { + setError(null); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${member.project_id}/members/${member.user_id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project_role: projectRole }), + }, + ); + if (!response.ok) { + setError(await response.text()); + return; + } + await loadMembers(); + }; + + const removeMember = async (member: ProjectMember) => { + setError(null); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${member.project_id}/members/${member.user_id}`, + { method: "DELETE" }, + ); + if (!response.ok) { + setError(await response.text()); + return; + } + setMessage("项目成员已移除"); + await loadMembers(); + }; + + return ( + <Box + sx={{ + minHeight: "100%", + overflow: "auto", + bgcolor: "background.default", + p: { xs: 2, md: 3 }, + }} + > + <Stack spacing={2.5} sx={{ maxWidth: 1440, mx: "auto" }}> + <Paper + variant="outlined" + sx={(theme) => ({ + ...cardSx, + p: { xs: 2, md: 3 }, + bgcolor: + theme.palette.mode === "dark" + ? alpha(theme.palette.primary.main, 0.1) + : alpha(theme.palette.primary.main, 0.04), + })} + > + <Stack + direction={{ xs: "column", md: "row" }} + spacing={2} + alignItems={{ xs: "stretch", md: "center" }} + justifyContent="space-between" + > + <Stack direction="row" spacing={1.5} alignItems="center"> + <Box + sx={(theme) => ({ + width: 48, + height: 48, + borderRadius: 2, + display: "grid", + placeItems: "center", + color: "primary.main", + bgcolor: alpha(theme.palette.primary.main, 0.12), + })} + > + <SecurityIcon /> + </Box> + <Box> + <Typography variant="h5" fontWeight={800}> + 系统管理 + </Typography> + <Typography variant="body2" color="text.secondary"> + Keycloak 负责登录身份,metadata 负责系统角色、账号状态和项目权限 + </Typography> + </Box> + </Stack> + {adminChecked && isAuthorized && ( + <Chip + color="success" + icon={<AdminPanelSettingsIcon />} + label="管理员权限已验证" + sx={{ alignSelf: { xs: "flex-start", md: "center" } }} + /> + )} + </Stack> + </Paper> + + {!adminChecked && ( + <Paper variant="outlined" sx={{ ...cardSx, p: 5 }}> + <Stack alignItems="center" spacing={2}> + <CircularProgress size={28} /> + <Typography color="text.secondary">正在校验系统管理权限</Typography> + </Stack> + </Paper> + )} + + {adminChecked && !isAuthorized && ( + <Alert severity="error" sx={{ borderRadius: 2 }}> + 无系统管理权限 + </Alert> + )} + {message && ( + <Alert severity="success" onClose={() => setMessage(null)} sx={{ borderRadius: 2 }}> + {message} + </Alert> + )} + {error && ( + <Alert severity="error" onClose={() => setError(null)} sx={{ borderRadius: 2 }}> + {error} + </Alert> + )} + + {isAuthorized && ( + <> + <Stack direction={{ xs: "column", md: "row" }} spacing={2}> + <StatCard + icon={<PeopleIcon />} + label="系统用户" + value={users.length} + /> + <StatCard + icon={<CheckCircleIcon />} + label="启用用户" + value={activeUsers} + tone="success" + /> + <StatCard + icon={<AdminPanelSettingsIcon />} + label="系统管理员角色" + value={systemAdminUsers} + tone="warning" + /> + <StatCard + icon={<SecurityIcon />} + label="超级管理员" + value={superUsers} + tone="info" + /> + <StatCard + icon={<GroupsIcon />} + label={hasProjectId ? "当前项目成员" : "项目成员未加载"} + value={hasProjectId ? members.length : "-"} + tone="info" + /> + </Stack> + + <Paper variant="outlined" sx={{ ...cardSx, overflow: "hidden" }}> + <Tabs + value={tab} + onChange={(_, value) => setTab(value)} + variant="scrollable" + scrollButtons="auto" + sx={{ + px: 2, + minHeight: 56, + borderBottom: 1, + borderColor: "divider", + "& .MuiTab-root": { + minHeight: 56, + textTransform: "none", + fontWeight: 700, + }, + }} + > + <Tab icon={<PeopleIcon />} iconPosition="start" label="用户" /> + <Tab icon={<GroupsIcon />} iconPosition="start" label="项目成员" /> + </Tabs> + + <Box sx={{ p: { xs: 2, md: 2.5 } }}> + {tab === 0 && ( + <Stack spacing={2}> + <SectionHeader + title="系统用户" + description="Keycloak 信息只读展示;这里维护 metadata 系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。" + action={ + <Button startIcon={<RefreshIcon />} variant="outlined" onClick={loadUsers}> + 刷新 + </Button> + } + /> + <TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}> + <Table size="small" sx={tableSx}> + <TableHead> + <TableRow> + <TableCell>用户身份</TableCell> + <TableCell>系统角色</TableCell> + <TableCell>超级权限</TableCell> + <TableCell>状态</TableCell> + <TableCell align="right">操作</TableCell> + </TableRow> + </TableHead> + <TableBody> + {users.length === 0 && <EmptyRow colSpan={5} label="暂无用户数据" />} + {users.map((user) => { + const isSelf = user.id === currentAdmin?.id; + const protectedUserReason = isSelf + ? "不能操作当前登录用户" + : user.is_superuser + ? "超级管理员权限独立于系统角色,需后台设置" + : ""; + + return ( + <TableRow key={user.id} hover> + <TableCell> + <Stack spacing={0.25}> + <Stack direction="row" spacing={1} alignItems="center"> + <Typography fontWeight={700}>{user.username}</Typography> + {isSelf && ( + <Chip size="small" color="info" label="当前用户" /> + )} + </Stack> + <Typography variant="body2" color="text.secondary"> + {user.email} + </Typography> + <Typography variant="caption" color="text.secondary"> + {user.keycloak_id} + </Typography> + </Stack> + </TableCell> + <TableCell> + <Tooltip title={protectedUserReason}> + <span> + <FormControl + size="small" + sx={{ minWidth: 150 }} + disabled={user.is_superuser || isSelf} + > + <Select + value={user.role} + onChange={(e) => updateUserRole(user, e.target.value)} + renderValue={(value) => + getBusinessRoleLabel(String(value)) + } + > + {businessRoleOptions.map((role) => ( + <MenuItem key={role.value} value={role.value}> + {role.label} + </MenuItem> + ))} + </Select> + </FormControl> + </span> + </Tooltip> + </TableCell> + <TableCell> + {user.is_superuser ? ( + <Chip + size="small" + color="warning" + icon={<SecurityIcon />} + label="超级管理员" + /> + ) : ( + <Chip size="small" label="无" variant="outlined" /> + )} + </TableCell> + <TableCell> + <StatusChip active={user.is_active} /> + </TableCell> + <TableCell align="right"> + <Tooltip + title={ + isSelf + ? "不能操作当前登录用户" + : user.is_superuser + ? "超级管理员不可禁用" + : "" + } + > + <span> + <Button + size="small" + variant="outlined" + color={user.is_active ? "warning" : "success"} + onClick={() => updateUserActive(user, !user.is_active)} + disabled={user.is_superuser || isSelf} + > + {user.is_active ? "禁用" : "启用"} + </Button> + </span> + </Tooltip> + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + </TableContainer> + </Stack> + )} + + {tab === 1 && ( + <Stack spacing={2}> + <SectionHeader + title="项目成员" + description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。" + action={ + <Button + variant="outlined" + startIcon={<RefreshIcon />} + onClick={loadMembers} + disabled={!hasProjectId} + > + 刷新成员 + </Button> + } + /> + {!hasProjectId && ( + <Alert severity="warning" sx={{ borderRadius: 2 }}> + 当前未选择项目,请先通过顶部用户菜单切换项目。 + </Alert> + )} + {hasProjectId && ( + <Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}> + <Stack + direction={{ xs: "column", md: "row" }} + spacing={1.5} + alignItems={{ xs: "stretch", md: "center" }} + justifyContent="space-between" + > + <Box> + <Typography variant="subtitle2" fontWeight={800}> + 当前项目 + </Typography> + <Typography variant="body2" color="text.secondary"> + {projectId} + </Typography> + </Box> + <Chip + color="info" + icon={<GroupsIcon />} + label={`${members.length} 名成员`} + sx={{ alignSelf: { xs: "flex-start", md: "center" } }} + /> + </Stack> + </Paper> + )} + <Paper + component="form" + variant="outlined" + sx={{ p: 2, borderRadius: 2, bgcolor: "action.hover" }} + onSubmit={addMember} + > + <Stack spacing={1.5}> + <Typography variant="subtitle2" fontWeight={800}> + 添加成员 + </Typography> + <Stack + direction="row" + spacing={1.5} + useFlexGap + flexWrap="wrap" + alignItems="center" + > + <FormControl + size="small" + disabled={!hasProjectId} + sx={{ + width: { xs: "100%", sm: 320, md: 360 }, + flexShrink: 0, + }} + > + <InputLabel>选择用户</InputLabel> + <Select + label="选择用户" + value={memberForm.user_id} + onChange={(e) => + setMemberForm({ ...memberForm, user_id: e.target.value }) + } + renderValue={() => selectedMemberUser?.username ?? ""} + required + > + {availableMemberUsers.length === 0 && ( + <MenuItem value="" disabled> + 暂无可添加用户 + </MenuItem> + )} + {availableMemberUsers.map((user) => ( + <MenuItem key={user.id} value={user.id}> + <Stack spacing={0.25} sx={{ minWidth: 0 }}> + <Typography variant="body2" fontWeight={700} noWrap> + {user.username} + </Typography> + <Typography + variant="caption" + color="text.secondary" + noWrap + > + {user.email} + </Typography> + </Stack> + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl + size="small" + sx={{ width: { xs: "calc(50% - 6px)", sm: 160 } }} + disabled={!hasProjectId} + > + <InputLabel>项目角色</InputLabel> + <Select + label="项目角色" + value={memberForm.project_role} + onChange={(e) => + setMemberForm({ ...memberForm, project_role: e.target.value }) + } + > + {projectRoleOptions.map((role) => ( + <MenuItem key={role.value} value={role.value}> + {role.label} + </MenuItem> + ))} + </Select> + </FormControl> + <Button + type="submit" + variant="contained" + startIcon={<AddIcon />} + disabled={!hasProjectId || !memberForm.user_id} + sx={{ + width: { xs: "calc(50% - 6px)", sm: "auto" }, + minWidth: 120, + }} + > + 添加成员 + </Button> + </Stack> + </Stack> + </Paper> + <TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}> + <Table size="small" sx={tableSx}> + <TableHead> + <TableRow> + <TableCell>用户</TableCell> + <TableCell>邮箱</TableCell> + <TableCell>项目角色</TableCell> + <TableCell>状态</TableCell> + <TableCell align="right">操作</TableCell> + </TableRow> + </TableHead> + <TableBody> + {members.length === 0 && ( + <EmptyRow colSpan={5} label="暂无项目成员数据" /> + )} + {members.map((member) => { + const isSelf = member.user_id === currentAdmin?.id; + const isOwner = member.project_role === "owner"; + + return ( + <TableRow key={member.id} hover> + <TableCell> + <Stack spacing={0.25}> + <Stack direction="row" spacing={1} alignItems="center"> + <Typography fontWeight={700}>{member.username}</Typography> + {isSelf && ( + <Chip size="small" color="info" label="当前用户" /> + )} + </Stack> + <Typography variant="caption" color="text.secondary"> + {member.user_id} + </Typography> + </Stack> + </TableCell> + <TableCell>{member.email}</TableCell> + <TableCell> + {isOwner ? ( + <Tooltip title="项目负责人不在系统管理页维护"> + <Chip + size="small" + color="warning" + label={getProjectRoleLabel(member.project_role)} + /> + </Tooltip> + ) : ( + <Tooltip title={isSelf ? "不能操作当前登录用户" : ""}> + <span> + <FormControl + size="small" + sx={{ minWidth: 140 }} + disabled={isSelf} + > + <Select + value={member.project_role} + onChange={(e) => + updateMemberRole(member, e.target.value) + } + renderValue={(value) => + getProjectRoleLabel(String(value)) + } + > + {projectRoleOptions.map((role) => ( + <MenuItem key={role.value} value={role.value}> + {role.label} + </MenuItem> + ))} + </Select> + </FormControl> + </span> + </Tooltip> + )} + </TableCell> + <TableCell> + <StatusChip active={member.is_active} /> + </TableCell> + <TableCell align="right"> + <Tooltip + title={ + isSelf + ? "不能操作当前登录用户" + : isOwner + ? "项目负责人不在系统管理页维护" + : "" + } + > + <span> + <Button + size="small" + color="error" + variant="outlined" + startIcon={<RemoveCircleOutlineIcon />} + onClick={() => removeMember(member)} + disabled={isSelf || isOwner} + > + 移除 + </Button> + </span> + </Tooltip> + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + </TableContainer> + </Stack> + )} + + </Box> + </Paper> + </> + )} + </Stack> + </Box> + ); +}; -- 2.54.0 From f6d2e193972812a1b23475f0dbc86977e639a14b Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 15:08:37 +0800 Subject: [PATCH 199/281] feat(admin): improve project management UI --- src/components/admin/SystemAdminPanel.tsx | 1361 ++++++++++++++++++++- 1 file changed, 1330 insertions(+), 31 deletions(-) diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index 306b22c..69d7303 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -9,7 +9,12 @@ import { Chip, CircularProgress, Divider, + Dialog, + DialogActions, + DialogContent, + DialogTitle, FormControl, + IconButton, InputLabel, MenuItem, Paper, @@ -23,6 +28,7 @@ import { TableHead, TableRow, Tabs, + TextField, Tooltip, Typography, } from "@mui/material"; @@ -31,11 +37,15 @@ import { AdminPanelSettings as AdminPanelSettingsIcon, Block as BlockIcon, CheckCircle as CheckCircleIcon, + Close as CloseIcon, + Dns as DnsIcon, Groups as GroupsIcon, People as PeopleIcon, Refresh as RefreshIcon, RemoveCircleOutline as RemoveCircleOutlineIcon, + Save as SaveIcon, Security as SecurityIcon, + Storage as StorageIcon, } from "@mui/icons-material"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; @@ -61,6 +71,43 @@ type ProjectMember = { is_active: boolean; }; +type AdminProject = { + project_id: string; + name: string; + code: string; + description?: string | null; + gs_workspace: string; + map_extent?: Record<string, unknown> | null; + status: string; +}; + +type ProjectDatabaseConfig = { + id: string; + project_id: string; + db_role: string; + db_type: string; + pool_min_size: number; + pool_max_size: number; + has_dsn: boolean; +}; + +type ProjectGeoServerConfig = { + id?: string | null; + project_id: string; + gs_base_url?: string | null; + gs_admin_user?: string | null; + gs_datastore_name: string; + default_extent?: Record<string, unknown> | null; + srid: number; + configured?: boolean; + has_password: boolean; +}; + +type DatabaseHealth = { + ok: boolean; + detail: string; +}; + const businessRoleOptions = [ { value: "admin", label: "系统管理员" }, { value: "operator", label: "运行人员" }, @@ -81,12 +128,120 @@ const projectRoleOptions = [ { value: "viewer", label: projectRoleLabels.viewer }, ]; +const projectStatusOptions = [ + { value: "active", label: "启用" }, + { value: "inactive", label: "停用" }, + { value: "archived", label: "归档" }, +]; + +const databaseRoleOptions = [ + { value: "biz_data", label: "业务数据库", helper: "PostgreSQL / 管网业务数据" }, + { value: "iot_data", label: "时序数据库", helper: "TimescaleDB / SCADA 与实时数据" }, +]; + +const defaultProjectForm = { + name: "", + code: "", + description: "", + gs_workspace: "", + map_extent: "", + status: "active", +}; + +const defaultDatabaseForms = { + biz_data: { + dsn: "", + pool_min_size: 2, + pool_max_size: 10, + }, + iot_data: { + dsn: "", + pool_min_size: 1, + pool_max_size: 10, + }, +}; + +const createDefaultDatabaseForms = () => ({ + biz_data: { ...defaultDatabaseForms.biz_data }, + iot_data: { ...defaultDatabaseForms.iot_data }, +}); + +const defaultGeoserverForm = { + gs_base_url: "", + gs_admin_user: "", + gs_admin_password: "", + gs_datastore_name: "ds_postgis", + default_extent: "", + srid: 4326, +}; + const getBusinessRoleLabel = (role: string) => businessRoleOptions.find((option) => option.value === role)?.label ?? role; const getProjectRoleLabel = (role: string) => projectRoleLabels[role] ?? role; +const formatJsonField = (value?: Record<string, unknown> | null) => + value ? JSON.stringify(value, null, 2) : ""; + +const parseOptionalJsonObject = (value: string, label: string) => { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} 必须是 JSON 对象`); + } + return parsed as Record<string, unknown>; +}; + +const readErrorText = async (response: Response) => { + const text = await response.text(); + return text || `HTTP ${response.status}`; +}; + +const isFastApiRouteNotFound = (response: Response, text: string) => + response.status === 404 && text.includes('"detail":"Not Found"'); + +const normalizeDatabaseHealthDetail = (detail: string, ok: boolean) => { + if (ok) return detail || "连通性测试通过"; + + const lowerDetail = detail.toLowerCase(); + if (lowerDetail.includes("password authentication failed")) { + return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"; + } + if (lowerDetail.includes("connection refused")) { + return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。"; + } + if (lowerDetail.includes("timeout") || lowerDetail.includes("timed out")) { + return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。"; + } + if ( + lowerDetail.includes("could not translate host name") || + lowerDetail.includes("name or service not known") + ) { + return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。"; + } + if (detail.startsWith("数据库连接失败:")) { + return detail.replace("数据库连接失败:", "连通性测试失败:"); + } + return detail || "连通性测试失败"; +}; + +const parseDatabaseHealthText = (text: string): DatabaseHealth | null => { + try { + const payload = JSON.parse(text); + if (payload && typeof payload === "object" && "ok" in payload) { + return { + ok: Boolean(payload.ok), + detail: String(payload.detail ?? ""), + }; + } + } catch { + return null; + } + return null; +}; + const cardSx = { borderRadius: 2, borderColor: "divider", @@ -206,9 +361,14 @@ export const SystemAdminPanel = () => { const [tab, setTab] = useState(0); const [users, setUsers] = useState<MetadataUser[]>([]); const [members, setMembers] = useState<ProjectMember[]>([]); + const [projects, setProjects] = useState<AdminProject[]>([]); + const [databases, setDatabases] = useState<ProjectDatabaseConfig[]>([]); + const [geoserverConfig, setGeoserverConfig] = + useState<ProjectGeoServerConfig | null>(null); const [currentAdmin, setCurrentAdmin] = useState<MetadataUser | null>(null); const [adminChecked, setAdminChecked] = useState(false); const [isAuthorized, setIsAuthorized] = useState(false); + const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true); const [projectId, setProjectId] = useState(currentProjectId ?? ""); const [hasLoadedCurrentProjectMembers, setHasLoadedCurrentProjectMembers] = useState(false); @@ -218,6 +378,17 @@ export const SystemAdminPanel = () => { user_id: "", project_role: "viewer", }); + const [projectForm, setProjectForm] = useState(defaultProjectForm); + const [createProjectOpen, setCreateProjectOpen] = useState(false); + const [createProjectForm, setCreateProjectForm] = useState(defaultProjectForm); + const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms); + const [databaseHealth, setDatabaseHealth] = useState< + Record<string, DatabaseHealth | null> + >({ + biz_data: null, + iot_data: null, + }); + const [geoserverForm, setGeoserverForm] = useState(defaultGeoserverForm); const activeUsers = useMemo( () => users.filter((user) => user.is_active).length, @@ -251,26 +422,146 @@ export const SystemAdminPanel = () => { [memberForm.user_id, users], ); const hasProjectId = Boolean(projectId.trim()); + const selectedProject = useMemo( + () => projects.find((project) => project.project_id === projectId.trim()), + [projectId, projects], + ); + const databasesByRole = useMemo( + () => + new Map( + databases.map((database) => [database.db_role, database] as const), + ), + [databases], + ); const loadUsers = useCallback(async () => { const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`); if (!response.ok) { - throw new Error(await response.text()); + throw new Error(await readErrorText(response)); } setUsers(await response.json()); }, []); + const applyProjectForm = useCallback((project: AdminProject | null) => { + if (!project) { + setProjectForm(defaultProjectForm); + return; + } + setProjectForm({ + name: project.name ?? "", + code: project.code ?? "", + description: project.description ?? "", + gs_workspace: project.gs_workspace ?? "", + map_extent: formatJsonField(project.map_extent), + status: project.status || "active", + }); + }, []); + + const loadProjects = useCallback(async (preferredProjectId?: string) => { + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + setProjects([]); + applyProjectForm(null); + return; + } + throw new Error(errorText); + } + setMetadataConfigAvailable(true); + const payload = (await response.json()) as AdminProject[]; + setProjects(payload); + const activeProjectId = + preferredProjectId || + projectId.trim() || + currentProjectId || + payload[0]?.project_id || + ""; + if (activeProjectId && !projectId.trim()) { + setProjectId(activeProjectId); + } + applyProjectForm( + payload.find((project) => project.project_id === activeProjectId) ?? null, + ); + }, [applyProjectForm, currentProjectId, projectId]); + const loadMembers = useCallback(async () => { if (!projectId.trim()) return; const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/members`, ); if (!response.ok) { - throw new Error(await response.text()); + throw new Error(await readErrorText(response)); } setMembers(await response.json()); }, [projectId]); + const loadDatabases = useCallback(async () => { + if (!projectId.trim()) return; + setDatabaseHealth({ biz_data: null, iot_data: null }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases`, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + setDatabases([]); + return; + } + throw new Error(errorText); + } + setMetadataConfigAvailable(true); + const payload = (await response.json()) as ProjectDatabaseConfig[]; + setDatabases(payload); + setDatabaseForms((current) => { + const next = createDefaultDatabaseForms(); + for (const database of payload) { + const role = database.db_role as keyof typeof defaultDatabaseForms; + if (role in next) { + next[role] = { + ...current[role], + dsn: "", + pool_min_size: database.pool_min_size, + pool_max_size: database.pool_max_size, + }; + } + } + return next; + }); + }, [projectId]); + + const loadGeoserverConfig = useCallback(async () => { + if (!projectId.trim()) return; + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/geoserver`, + ); + if (response.status === 404) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + } + setGeoserverConfig(null); + setGeoserverForm(defaultGeoserverForm); + return; + } + if (!response.ok) { + throw new Error(await readErrorText(response)); + } + setMetadataConfigAvailable(true); + const payload = (await response.json()) as ProjectGeoServerConfig; + setGeoserverConfig(payload); + setGeoserverForm({ + gs_base_url: payload.gs_base_url ?? "", + gs_admin_user: payload.gs_admin_user ?? "", + gs_admin_password: "", + gs_datastore_name: payload.gs_datastore_name || "ds_postgis", + default_extent: formatJsonField(payload.default_extent), + srid: payload.srid || 4326, + }); + }, [projectId]); + useEffect(() => { let cancelled = false; @@ -297,10 +588,39 @@ export const SystemAdminPanel = () => { const usersResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`); if (!usersResponse.ok) { - throw new Error(await usersResponse.text()); + throw new Error(await readErrorText(usersResponse)); } const payload = await usersResponse.json(); if (!cancelled) setUsers(payload); + + const projectsResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`); + if (!projectsResponse.ok) { + const errorText = await readErrorText(projectsResponse); + if (isFastApiRouteNotFound(projectsResponse, errorText)) { + if (!cancelled) { + setMetadataConfigAvailable(false); + setProjects([]); + applyProjectForm(null); + } + return; + } + throw new Error(errorText); + } + if (!cancelled) setMetadataConfigAvailable(true); + const projectsPayload = (await projectsResponse.json()) as AdminProject[]; + if (!cancelled) { + setProjects(projectsPayload); + const initialProjectId = + currentProjectId || projectsPayload[0]?.project_id || ""; + setProjectId((current) => + current.trim() || !initialProjectId ? current : initialProjectId, + ); + applyProjectForm( + projectsPayload.find( + (project) => project.project_id === initialProjectId, + ) ?? null, + ); + } } catch (err) { if (!cancelled) { setAdminChecked(true); @@ -314,7 +634,7 @@ export const SystemAdminPanel = () => { return () => { cancelled = true; }; - }, []); + }, [applyProjectForm, currentProjectId]); useEffect(() => { if (!currentProjectId || projectId.trim()) return; @@ -328,6 +648,30 @@ export const SystemAdminPanel = () => { loadMembers().catch((err) => setError(String(err))); }, [hasLoadedCurrentProjectMembers, hasProjectId, isAuthorized, loadMembers]); + useEffect(() => { + if (!selectedProject) return; + applyProjectForm(selectedProject); + }, [applyProjectForm, selectedProject]); + + useEffect(() => { + if (!isAuthorized || !hasProjectId || !metadataConfigAvailable) return; + setDatabaseHealth({ biz_data: null, iot_data: null }); + loadDatabases().catch((err) => setError(String(err))); + loadGeoserverConfig().catch((err) => setError(String(err))); + }, [ + hasProjectId, + isAuthorized, + loadDatabases, + loadGeoserverConfig, + metadataConfigAvailable, + ]); + + useEffect(() => { + if (!metadataConfigAvailable && tab !== 4) { + setTab(4); + } + }, [metadataConfigAvailable, tab]); + const updateUserActive = async (user: MetadataUser, isActive: boolean) => { setError(null); const response = await apiFetch( @@ -339,7 +683,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } await loadUsers(); @@ -356,7 +700,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } await loadUsers(); @@ -375,7 +719,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } setMessage("项目成员已添加"); @@ -394,7 +738,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } await loadMembers(); @@ -407,13 +751,257 @@ export const SystemAdminPanel = () => { { method: "DELETE" }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } setMessage("项目成员已移除"); await loadMembers(); }; + const startNewProject = () => { + setCreateProjectForm(defaultProjectForm); + setCreateProjectOpen(true); + }; + + const selectProject = (value: string) => { + setProjectId(value); + setHasLoadedCurrentProjectMembers(false); + setMembers([]); + setDatabases([]); + setDatabaseForms(createDefaultDatabaseForms()); + setGeoserverConfig(null); + setGeoserverForm(defaultGeoserverForm); + setDatabaseHealth({ biz_data: null, iot_data: null }); + }; + + const resetDatabaseHealth = (role: keyof typeof defaultDatabaseForms) => { + setDatabaseHealth((current) => ({ ...current, [role]: null })); + }; + + const buildProjectPayload = (form: typeof defaultProjectForm) => ({ + name: form.name.trim(), + code: form.code.trim(), + description: form.description.trim() || null, + gs_workspace: form.gs_workspace.trim(), + map_extent: parseOptionalJsonObject(form.map_extent, "地图范围"), + status: form.status, + }); + + const saveProject = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedProject?.project_id) { + setError("请先选择要编辑的项目。"); + return; + } + setError(null); + setMessage(null); + try { + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${selectedProject.project_id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildProjectPayload(projectForm)), + }, + ); + if (!response.ok) { + throw new Error(await readErrorText(response)); + } + const saved = (await response.json()) as AdminProject; + setHasLoadedCurrentProjectMembers(false); + setProjectId(saved.project_id); + applyProjectForm(saved); + await loadProjects(saved.project_id); + setMessage("项目配置已更新"); + } catch (err) { + setError(String(err)); + } + }; + + const createProject = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setMessage(null); + try { + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildProjectPayload(createProjectForm)), + }); + if (!response.ok) { + throw new Error(await readErrorText(response)); + } + const saved = (await response.json()) as AdminProject; + setCreateProjectOpen(false); + setCreateProjectForm(defaultProjectForm); + setHasLoadedCurrentProjectMembers(false); + setProjectId(saved.project_id); + applyProjectForm(saved); + await loadProjects(saved.project_id); + setMessage("项目已创建"); + } catch (err) { + setError(String(err)); + } + }; + + const saveDatabase = async (role: keyof typeof defaultDatabaseForms) => { + if (!projectId.trim()) return; + const form = databaseForms[role]; + if (!form.dsn.trim()) { + setError("请填写新的 DSN 并通过连通性测试后再保存。"); + return; + } + if (!databaseHealth[role]?.ok) { + setError("请先通过连通性测试再保存数据库配置。"); + return; + } + setError(null); + setMessage(null); + const payload: Record<string, unknown> = { + db_role: role, + pool_min_size: Number(form.pool_min_size), + pool_max_size: Number(form.pool_max_size), + }; + if (form.dsn.trim()) { + payload.dsn = form.dsn.trim(); + } + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + setError(errorText); + return; + } + setDatabaseForms((current) => ({ + ...current, + [role]: { ...current[role], dsn: "" }, + })); + setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}已保存`); + await loadDatabases(); + }; + + const checkDatabaseHealth = async (role: keyof typeof defaultDatabaseForms) => { + if (!projectId.trim()) return; + setError(null); + setDatabaseHealth((current) => ({ ...current, [role]: null })); + const form = databaseForms[role]; + const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {}; + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + const healthPayload = parseDatabaseHealthText(errorText); + if (healthPayload) { + setDatabaseHealth((current) => ({ + ...current, + [role]: { + ...healthPayload, + detail: normalizeDatabaseHealthDetail( + healthPayload.detail, + healthPayload.ok, + ), + }, + })); + return; + } + setError(normalizeDatabaseHealthDetail(errorText, false)); + return; + } + const payload = (await response.json()) as DatabaseHealth; + setDatabaseHealth((current) => ({ + ...current, + [role]: { + ...payload, + detail: normalizeDatabaseHealthDetail(payload.detail, payload.ok), + }, + })); + }; + + const deleteDatabase = async (role: keyof typeof defaultDatabaseForms) => { + if (!projectId.trim()) return; + setError(null); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}`, + { method: "DELETE" }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + setError(errorText); + return; + } + setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}配置已删除`); + await loadDatabases(); + }; + + const saveGeoserverConfig = async (event: FormEvent) => { + event.preventDefault(); + if (!projectId.trim()) return; + setError(null); + setMessage(null); + try { + const payload: Record<string, unknown> = { + gs_base_url: geoserverForm.gs_base_url.trim() || null, + gs_admin_user: geoserverForm.gs_admin_user.trim() || null, + gs_datastore_name: geoserverForm.gs_datastore_name.trim() || "ds_postgis", + default_extent: parseOptionalJsonObject( + geoserverForm.default_extent, + "默认范围", + ), + srid: Number(geoserverForm.srid), + }; + if (geoserverForm.gs_admin_password.trim()) { + payload.gs_admin_password = geoserverForm.gs_admin_password.trim(); + } + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/geoserver`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + throw new Error(errorText); + } + const saved = (await response.json()) as ProjectGeoServerConfig; + setGeoserverConfig(saved); + setGeoserverForm((current) => ({ ...current, gs_admin_password: "" })); + setMessage("GeoServer 配置已保存"); + await loadGeoserverConfig(); + } catch (err) { + setError(String(err)); + } + }; + return ( <Box sx={{ @@ -460,7 +1048,7 @@ export const SystemAdminPanel = () => { 系统管理 </Typography> <Typography variant="body2" color="text.secondary"> - Keycloak 负责登录身份,metadata 负责系统角色、账号状态和项目权限 + Keycloak 负责登录身份,系统配置库负责系统角色、账号状态和项目权限 </Typography> </Box> </Stack> @@ -499,6 +1087,11 @@ export const SystemAdminPanel = () => { {error} </Alert> )} + {isAuthorized && !metadataConfigAvailable && ( + <Alert severity="warning" sx={{ borderRadius: 2 }}> + 后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置、数据库配置和 GeoServer 配置。 + </Alert> + )} {isAuthorized && ( <> @@ -532,8 +1125,71 @@ export const SystemAdminPanel = () => { value={hasProjectId ? members.length : "-"} tone="info" /> + <StatCard + icon={<DnsIcon />} + label="系统项目" + value={projects.length} + tone="primary" + /> </Stack> + {metadataConfigAvailable && ( + <Paper variant="outlined" sx={{ ...cardSx, p: 2 }}> + <Stack + direction={{ xs: "column", md: "row" }} + spacing={2} + alignItems={{ xs: "stretch", md: "center" }} + justifyContent="space-between" + > + <Box sx={{ minWidth: 0 }}> + <Typography variant="subtitle2" fontWeight={800}> + 当前管理项目 + </Typography> + <Typography variant="body2" color="text.secondary" noWrap> + {selectedProject + ? `${selectedProject.name} / ${selectedProject.code}` + : "未选择项目"} + </Typography> + </Box> + <Stack direction={{ xs: "column", sm: "row" }} spacing={1}> + <FormControl size="small" sx={{ minWidth: { xs: "100%", sm: 280 } }}> + <InputLabel>选择项目</InputLabel> + <Select + label="选择项目" + value={selectedProject?.project_id ?? ""} + onChange={(event) => selectProject(event.target.value)} + > + {projects.length === 0 && ( + <MenuItem value="" disabled> + 暂无项目 + </MenuItem> + )} + {projects.map((project) => ( + <MenuItem key={project.project_id} value={project.project_id}> + {project.name} / {project.code} + </MenuItem> + ))} + </Select> + </FormControl> + <Button + startIcon={<RefreshIcon />} + variant="outlined" + onClick={() => loadProjects()} + > + 刷新项目 + </Button> + <Button + startIcon={<AddIcon />} + variant="contained" + onClick={startNewProject} + > + 新建项目 + </Button> + </Stack> + </Stack> + </Paper> + )} + <Paper variant="outlined" sx={{ ...cardSx, overflow: "hidden" }}> <Tabs value={tab} @@ -552,16 +1208,39 @@ export const SystemAdminPanel = () => { }, }} > - <Tab icon={<PeopleIcon />} iconPosition="start" label="用户" /> - <Tab icon={<GroupsIcon />} iconPosition="start" label="项目成员" /> + <Tab + icon={<GroupsIcon />} + iconPosition="start" + label="项目成员" + disabled={!metadataConfigAvailable} + /> + <Tab + icon={<DnsIcon />} + iconPosition="start" + label="项目配置" + disabled={!metadataConfigAvailable} + /> + <Tab + icon={<StorageIcon />} + iconPosition="start" + label="数据库配置" + disabled={!metadataConfigAvailable} + /> + <Tab + icon={<SecurityIcon />} + iconPosition="start" + label="GeoServer" + disabled={!metadataConfigAvailable} + /> + <Tab icon={<PeopleIcon />} iconPosition="start" label="系统用户" /> </Tabs> <Box sx={{ p: { xs: 2, md: 2.5 } }}> - {tab === 0 && ( + {tab === 4 && ( <Stack spacing={2}> <SectionHeader title="系统用户" - description="Keycloak 信息只读展示;这里维护 metadata 系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。" + description="Keycloak 信息只读展示;这里维护系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。" action={ <Button startIcon={<RefreshIcon />} variant="outlined" onClick={loadUsers}> 刷新 @@ -679,7 +1358,7 @@ export const SystemAdminPanel = () => { </Stack> )} - {tab === 1 && ( + {tab === 0 && ( <Stack spacing={2}> <SectionHeader title="项目成员" @@ -697,31 +1376,63 @@ export const SystemAdminPanel = () => { /> {!hasProjectId && ( <Alert severity="warning" sx={{ borderRadius: 2 }}> - 当前未选择项目,请先通过顶部用户菜单切换项目。 + 当前未选择管理项目。 </Alert> )} {hasProjectId && ( <Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}> <Stack direction={{ xs: "column", md: "row" }} - spacing={1.5} + spacing={2} alignItems={{ xs: "stretch", md: "center" }} justifyContent="space-between" > - <Box> - <Typography variant="subtitle2" fontWeight={800}> - 当前项目 - </Typography> - <Typography variant="body2" color="text.secondary"> - {projectId} - </Typography> - </Box> - <Chip - color="info" - icon={<GroupsIcon />} - label={`${members.length} 名成员`} - sx={{ alignSelf: { xs: "flex-start", md: "center" } }} - /> + <Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0 }}> + <Box + sx={(theme) => ({ + width: 40, + height: 40, + borderRadius: 1.5, + display: "grid", + placeItems: "center", + color: "primary.main", + bgcolor: alpha(theme.palette.primary.main, 0.12), + flexShrink: 0, + })} + > + <DnsIcon /> + </Box> + <Box sx={{ minWidth: 0 }}> + <Typography variant="caption" color="text.secondary"> + 当前管理项目 + </Typography> + <Typography variant="subtitle1" fontWeight={800} noWrap> + {selectedProject?.name ?? "未命名项目"} + </Typography> + <Typography variant="body2" color="text.secondary" noWrap> + {selectedProject + ? `${selectedProject.code} · ${projectId}` + : projectId} + </Typography> + </Box> + </Stack> + <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> + {selectedProject && ( + <Chip + color={selectedProject.status === "active" ? "success" : "default"} + label={ + projectStatusOptions.find( + (option) => option.value === selectedProject.status, + )?.label ?? selectedProject.status + } + /> + )} + <Chip + color="info" + icon={<GroupsIcon />} + label={`${members.length} 名成员`} + /> + </Stack> </Stack> </Paper> )} @@ -926,8 +1637,596 @@ export const SystemAdminPanel = () => { </Stack> )} + {tab === 1 && ( + <Stack spacing={2}> + <SectionHeader + title="项目配置" + description="维护项目基础信息、GeoServer 工作区、地图范围和项目状态。" + action={ + <Stack direction="row" spacing={1}> + <Button startIcon={<RefreshIcon />} variant="outlined" onClick={() => loadProjects()}> + 刷新 + </Button> + </Stack> + } + /> + {!selectedProject && ( + <Alert severity="warning" sx={{ borderRadius: 2 }}> + 当前未选择管理项目。请在页面顶部选择项目,或使用“新建项目”创建项目。 + </Alert> + )} + <Paper + component="form" + variant="outlined" + sx={{ p: 2, borderRadius: 2 }} + onSubmit={saveProject} + > + <Stack spacing={2}> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + label="项目名称" + value={projectForm.name} + onChange={(event) => + setProjectForm({ ...projectForm, name: event.target.value }) + } + disabled={!selectedProject} + required + fullWidth + /> + <TextField + size="small" + label="项目代码" + value={projectForm.code} + onChange={(event) => + setProjectForm({ ...projectForm, code: event.target.value }) + } + disabled={!selectedProject} + required + fullWidth + /> + <TextField + size="small" + label="GeoServer 工作区" + value={projectForm.gs_workspace} + onChange={(event) => + setProjectForm({ + ...projectForm, + gs_workspace: event.target.value, + }) + } + disabled={!selectedProject} + required + fullWidth + /> + <FormControl size="small" sx={{ minWidth: 140 }} disabled={!selectedProject}> + <InputLabel>状态</InputLabel> + <Select + label="状态" + value={projectForm.status} + onChange={(event) => + setProjectForm({ + ...projectForm, + status: event.target.value, + }) + } + > + {projectStatusOptions.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + </FormControl> + </Stack> + <TextField + size="small" + label="描述" + value={projectForm.description} + onChange={(event) => + setProjectForm({ + ...projectForm, + description: event.target.value, + }) + } + disabled={!selectedProject} + fullWidth + multiline + minRows={2} + /> + <TextField + size="small" + label="地图范围 JSON" + value={projectForm.map_extent} + onChange={(event) => + setProjectForm({ + ...projectForm, + map_extent: event.target.value, + }) + } + disabled={!selectedProject} + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + <Stack direction="row" justifyContent="flex-end"> + <Button + type="submit" + variant="contained" + startIcon={<SaveIcon />} + disabled={!selectedProject} + > + 保存项目 + </Button> + </Stack> + </Stack> + </Paper> + </Stack> + )} + + {tab === 2 && ( + <Stack spacing={2}> + <SectionHeader + title="项目数据库配置" + description="配置当前管理项目的 biz_data 和 iot_data 路由;填写或修改后先测试连通性,通过后才能保存。" + action={ + <Button + startIcon={<RefreshIcon />} + variant="outlined" + onClick={loadDatabases} + disabled={!hasProjectId} + > + 刷新配置 + </Button> + } + /> + {!hasProjectId && ( + <Alert severity="warning" sx={{ borderRadius: 2 }}> + 当前未选择管理项目。 + </Alert> + )} + {hasProjectId && + databaseRoleOptions.map((roleOption) => { + const role = roleOption.value as keyof typeof defaultDatabaseForms; + const form = databaseForms[role]; + const configRecord = databasesByRole.get(role); + const health = databaseHealth[role]; + + return ( + <Paper key={role} variant="outlined" sx={{ p: 2, borderRadius: 2 }}> + <Stack spacing={2}> + <Stack + direction={{ xs: "column", md: "row" }} + spacing={1} + alignItems={{ xs: "stretch", md: "center" }} + justifyContent="space-between" + > + <Box> + <Typography variant="subtitle2" fontWeight={800}> + {roleOption.label} + </Typography> + <Typography variant="body2" color="text.secondary"> + {roleOption.helper} + </Typography> + </Box> + <Chip + color={configRecord?.has_dsn ? "success" : "default"} + label={configRecord?.has_dsn ? "DSN 已配置" : "未配置 DSN"} + sx={{ alignSelf: { xs: "flex-start", md: "center" } }} + /> + </Stack> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + type="password" + label={configRecord?.has_dsn ? "替换 DSN" : "DSN"} + value={form.dsn} + onChange={(event) => { + resetDatabaseHealth(role); + setDatabaseForms((current) => ({ + ...current, + [role]: { ...current[role], dsn: event.target.value }, + })); + }} + placeholder="postgresql://user:password@host:5432/db" + helperText={ + configRecord?.has_dsn + ? "填写新 DSN 后测试新连接,通过后才能保存;留空只测试已保存 DSN。" + : "首次保存前必须填写 DSN 并通过连通性测试。" + } + fullWidth + /> + <TextField + size="small" + type="number" + label="最小连接" + value={form.pool_min_size} + onChange={(event) => { + resetDatabaseHealth(role); + setDatabaseForms((current) => ({ + ...current, + [role]: { + ...current[role], + pool_min_size: Number(event.target.value), + }, + })); + }} + sx={{ width: { xs: "100%", md: 120 } }} + /> + <TextField + size="small" + type="number" + label="最大连接" + value={form.pool_max_size} + onChange={(event) => { + resetDatabaseHealth(role); + setDatabaseForms((current) => ({ + ...current, + [role]: { + ...current[role], + pool_max_size: Number(event.target.value), + }, + })); + }} + sx={{ width: { xs: "100%", md: 120 } }} + /> + </Stack> + {health && ( + <Alert + severity={health.ok ? "success" : "error"} + sx={{ borderRadius: 2 }} + > + {health.detail} + </Alert> + )} + <Stack direction="row" spacing={1} justifyContent="flex-end"> + <Button + variant="outlined" + onClick={() => checkDatabaseHealth(role)} + disabled={!configRecord?.has_dsn && !form.dsn.trim()} + > + 测试连通性 + </Button> + <Button + variant="outlined" + color="error" + onClick={() => deleteDatabase(role)} + disabled={!configRecord?.has_dsn} + > + 删除 + </Button> + <Button + variant="contained" + startIcon={<SaveIcon />} + onClick={() => saveDatabase(role)} + disabled={!form.dsn.trim() || !health?.ok} + > + 保存 + </Button> + </Stack> + </Stack> + </Paper> + ); + })} + </Stack> + )} + + {tab === 3 && ( + <Stack spacing={2}> + <SectionHeader + title="项目 GeoServer 配置" + description="维护当前管理项目的 GeoServer 服务、管理员账号、datastore、默认范围和 SRID。" + action={ + <Button + startIcon={<RefreshIcon />} + variant="outlined" + onClick={loadGeoserverConfig} + disabled={!hasProjectId} + > + 刷新配置 + </Button> + } + /> + {!hasProjectId && ( + <Alert severity="warning" sx={{ borderRadius: 2 }}> + 当前未选择管理项目。 + </Alert> + )} + {hasProjectId && ( + <Paper + component="form" + variant="outlined" + sx={{ p: 2, borderRadius: 2 }} + onSubmit={saveGeoserverConfig} + > + <Stack spacing={2}> + <Stack + direction={{ xs: "column", md: "row" }} + spacing={1} + alignItems={{ xs: "stretch", md: "center" }} + justifyContent="space-between" + > + <Box> + <Typography variant="subtitle2" fontWeight={800}> + {selectedProject?.name ?? projectId} + </Typography> + <Typography variant="body2" color="text.secondary"> + {selectedProject?.gs_workspace ?? "未设置工作区"} + </Typography> + </Box> + <Chip + color={geoserverConfig?.has_password ? "success" : "default"} + label={ + geoserverConfig?.has_password ? "密码已配置" : "未配置密码" + } + sx={{ alignSelf: { xs: "flex-start", md: "center" } }} + /> + </Stack> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + label="Base URL" + value={geoserverForm.gs_base_url} + onChange={(event) => + setGeoserverForm({ + ...geoserverForm, + gs_base_url: event.target.value, + }) + } + fullWidth + /> + <TextField + size="small" + label="管理员账号" + value={geoserverForm.gs_admin_user} + onChange={(event) => + setGeoserverForm({ + ...geoserverForm, + gs_admin_user: event.target.value, + }) + } + fullWidth + /> + <TextField + size="small" + type="password" + label="管理员密码" + value={geoserverForm.gs_admin_password} + onChange={(event) => + setGeoserverForm({ + ...geoserverForm, + gs_admin_password: event.target.value, + }) + } + fullWidth + /> + </Stack> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + label="Datastore" + value={geoserverForm.gs_datastore_name} + onChange={(event) => + setGeoserverForm({ + ...geoserverForm, + gs_datastore_name: event.target.value, + }) + } + fullWidth + required + /> + <TextField + size="small" + type="number" + label="SRID" + value={geoserverForm.srid} + onChange={(event) => + setGeoserverForm({ + ...geoserverForm, + srid: Number(event.target.value), + }) + } + sx={{ width: { xs: "100%", md: 180 } }} + required + /> + </Stack> + <TextField + size="small" + label="默认范围 JSON" + value={geoserverForm.default_extent} + onChange={(event) => + setGeoserverForm({ + ...geoserverForm, + default_extent: event.target.value, + }) + } + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + <Stack direction="row" justifyContent="flex-end"> + <Button type="submit" variant="contained" startIcon={<SaveIcon />}> + 保存 GeoServer + </Button> + </Stack> + </Stack> + </Paper> + )} + </Stack> + )} + </Box> </Paper> + <Dialog + open={createProjectOpen} + onClose={() => setCreateProjectOpen(false)} + fullWidth + maxWidth="md" + PaperProps={{ + sx: { + borderRadius: 2, + overflow: "hidden", + }, + }} + > + <Box component="form" onSubmit={createProject}> + <DialogTitle sx={{ px: 3, py: 2 }}> + <Stack direction="row" alignItems="center" justifyContent="space-between"> + <Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0 }}> + <Box + sx={(theme) => ({ + width: 40, + height: 40, + borderRadius: 1.5, + display: "grid", + placeItems: "center", + color: "primary.main", + bgcolor: alpha(theme.palette.primary.main, 0.12), + flexShrink: 0, + })} + > + <DnsIcon /> + </Box> + <Box sx={{ minWidth: 0 }}> + <Typography variant="h6" fontWeight={800} lineHeight={1.2}> + 新建项目 + </Typography> + <Typography variant="body2" color="text.secondary" noWrap> + 系统项目基础配置 + </Typography> + </Box> + </Stack> + <IconButton + aria-label="关闭" + onClick={() => setCreateProjectOpen(false)} + edge="end" + > + <CloseIcon /> + </IconButton> + </Stack> + </DialogTitle> + <DialogContent dividers sx={{ px: 3, py: 2.5 }}> + <Stack spacing={2.5}> + <Box> + <Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}> + 基础信息 + </Typography> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + label="项目名称" + value={createProjectForm.name} + onChange={(event) => + setCreateProjectForm({ + ...createProjectForm, + name: event.target.value, + }) + } + required + fullWidth + /> + <TextField + size="small" + label="项目代码" + value={createProjectForm.code} + onChange={(event) => + setCreateProjectForm({ + ...createProjectForm, + code: event.target.value, + }) + } + required + fullWidth + /> + </Stack> + </Box> + <Box> + <Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}> + 服务配置 + </Typography> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + label="GeoServer 工作区" + value={createProjectForm.gs_workspace} + onChange={(event) => + setCreateProjectForm({ + ...createProjectForm, + gs_workspace: event.target.value, + }) + } + required + fullWidth + /> + <FormControl size="small" sx={{ minWidth: { xs: "100%", md: 160 } }}> + <InputLabel>状态</InputLabel> + <Select + label="状态" + value={createProjectForm.status} + onChange={(event) => + setCreateProjectForm({ + ...createProjectForm, + status: event.target.value, + }) + } + > + {projectStatusOptions.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + </FormControl> + </Stack> + </Box> + <Box> + <Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}> + 描述与范围 + </Typography> + <Stack spacing={1.5}> + <TextField + size="small" + label="描述" + value={createProjectForm.description} + onChange={(event) => + setCreateProjectForm({ + ...createProjectForm, + description: event.target.value, + }) + } + fullWidth + multiline + minRows={2} + /> + <TextField + size="small" + label="地图范围 JSON" + value={createProjectForm.map_extent} + onChange={(event) => + setCreateProjectForm({ + ...createProjectForm, + map_extent: event.target.value, + }) + } + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + </Stack> + </Box> + </Stack> + </DialogContent> + <DialogActions sx={{ px: 3, py: 2, bgcolor: "action.hover" }}> + <Button onClick={() => setCreateProjectOpen(false)}>取消</Button> + <Button type="submit" variant="contained" startIcon={<SaveIcon />}> + 创建项目 + </Button> + </DialogActions> + </Box> + </Dialog> </> )} </Stack> -- 2.54.0 From 7cd0c6118130a42f9f36a7997e6ce072c275ff2e Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 15:28:14 +0800 Subject: [PATCH 200/281] refactor(admin): remove geoserver config UI --- src/components/admin/SystemAdminPanel.tsx | 262 +--------------------- 1 file changed, 4 insertions(+), 258 deletions(-) diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index 69d7303..246528c 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -91,18 +91,6 @@ type ProjectDatabaseConfig = { has_dsn: boolean; }; -type ProjectGeoServerConfig = { - id?: string | null; - project_id: string; - gs_base_url?: string | null; - gs_admin_user?: string | null; - gs_datastore_name: string; - default_extent?: Record<string, unknown> | null; - srid: number; - configured?: boolean; - has_password: boolean; -}; - type DatabaseHealth = { ok: boolean; detail: string; @@ -166,15 +154,6 @@ const createDefaultDatabaseForms = () => ({ iot_data: { ...defaultDatabaseForms.iot_data }, }); -const defaultGeoserverForm = { - gs_base_url: "", - gs_admin_user: "", - gs_admin_password: "", - gs_datastore_name: "ds_postgis", - default_extent: "", - srid: 4326, -}; - const getBusinessRoleLabel = (role: string) => businessRoleOptions.find((option) => option.value === role)?.label ?? role; @@ -363,8 +342,6 @@ export const SystemAdminPanel = () => { const [members, setMembers] = useState<ProjectMember[]>([]); const [projects, setProjects] = useState<AdminProject[]>([]); const [databases, setDatabases] = useState<ProjectDatabaseConfig[]>([]); - const [geoserverConfig, setGeoserverConfig] = - useState<ProjectGeoServerConfig | null>(null); const [currentAdmin, setCurrentAdmin] = useState<MetadataUser | null>(null); const [adminChecked, setAdminChecked] = useState(false); const [isAuthorized, setIsAuthorized] = useState(false); @@ -388,7 +365,6 @@ export const SystemAdminPanel = () => { biz_data: null, iot_data: null, }); - const [geoserverForm, setGeoserverForm] = useState(defaultGeoserverForm); const activeUsers = useMemo( () => users.filter((user) => user.is_active).length, @@ -532,36 +508,6 @@ export const SystemAdminPanel = () => { }); }, [projectId]); - const loadGeoserverConfig = useCallback(async () => { - if (!projectId.trim()) return; - const response = await apiFetch( - `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/geoserver`, - ); - if (response.status === 404) { - const errorText = await readErrorText(response); - if (isFastApiRouteNotFound(response, errorText)) { - setMetadataConfigAvailable(false); - } - setGeoserverConfig(null); - setGeoserverForm(defaultGeoserverForm); - return; - } - if (!response.ok) { - throw new Error(await readErrorText(response)); - } - setMetadataConfigAvailable(true); - const payload = (await response.json()) as ProjectGeoServerConfig; - setGeoserverConfig(payload); - setGeoserverForm({ - gs_base_url: payload.gs_base_url ?? "", - gs_admin_user: payload.gs_admin_user ?? "", - gs_admin_password: "", - gs_datastore_name: payload.gs_datastore_name || "ds_postgis", - default_extent: formatJsonField(payload.default_extent), - srid: payload.srid || 4326, - }); - }, [projectId]); - useEffect(() => { let cancelled = false; @@ -657,18 +603,16 @@ export const SystemAdminPanel = () => { if (!isAuthorized || !hasProjectId || !metadataConfigAvailable) return; setDatabaseHealth({ biz_data: null, iot_data: null }); loadDatabases().catch((err) => setError(String(err))); - loadGeoserverConfig().catch((err) => setError(String(err))); }, [ hasProjectId, isAuthorized, loadDatabases, - loadGeoserverConfig, metadataConfigAvailable, ]); useEffect(() => { - if (!metadataConfigAvailable && tab !== 4) { - setTab(4); + if (!metadataConfigAvailable && tab !== 3) { + setTab(3); } }, [metadataConfigAvailable, tab]); @@ -769,8 +713,6 @@ export const SystemAdminPanel = () => { setMembers([]); setDatabases([]); setDatabaseForms(createDefaultDatabaseForms()); - setGeoserverConfig(null); - setGeoserverForm(defaultGeoserverForm); setDatabaseHealth({ biz_data: null, iot_data: null }); }; @@ -957,51 +899,6 @@ export const SystemAdminPanel = () => { await loadDatabases(); }; - const saveGeoserverConfig = async (event: FormEvent) => { - event.preventDefault(); - if (!projectId.trim()) return; - setError(null); - setMessage(null); - try { - const payload: Record<string, unknown> = { - gs_base_url: geoserverForm.gs_base_url.trim() || null, - gs_admin_user: geoserverForm.gs_admin_user.trim() || null, - gs_datastore_name: geoserverForm.gs_datastore_name.trim() || "ds_postgis", - default_extent: parseOptionalJsonObject( - geoserverForm.default_extent, - "默认范围", - ), - srid: Number(geoserverForm.srid), - }; - if (geoserverForm.gs_admin_password.trim()) { - payload.gs_admin_password = geoserverForm.gs_admin_password.trim(); - } - const response = await apiFetch( - `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/geoserver`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - if (!response.ok) { - const errorText = await readErrorText(response); - if (isFastApiRouteNotFound(response, errorText)) { - setMetadataConfigAvailable(false); - return; - } - throw new Error(errorText); - } - const saved = (await response.json()) as ProjectGeoServerConfig; - setGeoserverConfig(saved); - setGeoserverForm((current) => ({ ...current, gs_admin_password: "" })); - setMessage("GeoServer 配置已保存"); - await loadGeoserverConfig(); - } catch (err) { - setError(String(err)); - } - }; - return ( <Box sx={{ @@ -1089,7 +986,7 @@ export const SystemAdminPanel = () => { )} {isAuthorized && !metadataConfigAvailable && ( <Alert severity="warning" sx={{ borderRadius: 2 }}> - 后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置、数据库配置和 GeoServer 配置。 + 后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置和数据库配置。 </Alert> )} @@ -1226,17 +1123,11 @@ export const SystemAdminPanel = () => { label="数据库配置" disabled={!metadataConfigAvailable} /> - <Tab - icon={<SecurityIcon />} - iconPosition="start" - label="GeoServer" - disabled={!metadataConfigAvailable} - /> <Tab icon={<PeopleIcon />} iconPosition="start" label="系统用户" /> </Tabs> <Box sx={{ p: { xs: 2, md: 2.5 } }}> - {tab === 4 && ( + {tab === 3 && ( <Stack spacing={2}> <SectionHeader title="系统用户" @@ -1912,151 +1803,6 @@ export const SystemAdminPanel = () => { </Stack> )} - {tab === 3 && ( - <Stack spacing={2}> - <SectionHeader - title="项目 GeoServer 配置" - description="维护当前管理项目的 GeoServer 服务、管理员账号、datastore、默认范围和 SRID。" - action={ - <Button - startIcon={<RefreshIcon />} - variant="outlined" - onClick={loadGeoserverConfig} - disabled={!hasProjectId} - > - 刷新配置 - </Button> - } - /> - {!hasProjectId && ( - <Alert severity="warning" sx={{ borderRadius: 2 }}> - 当前未选择管理项目。 - </Alert> - )} - {hasProjectId && ( - <Paper - component="form" - variant="outlined" - sx={{ p: 2, borderRadius: 2 }} - onSubmit={saveGeoserverConfig} - > - <Stack spacing={2}> - <Stack - direction={{ xs: "column", md: "row" }} - spacing={1} - alignItems={{ xs: "stretch", md: "center" }} - justifyContent="space-between" - > - <Box> - <Typography variant="subtitle2" fontWeight={800}> - {selectedProject?.name ?? projectId} - </Typography> - <Typography variant="body2" color="text.secondary"> - {selectedProject?.gs_workspace ?? "未设置工作区"} - </Typography> - </Box> - <Chip - color={geoserverConfig?.has_password ? "success" : "default"} - label={ - geoserverConfig?.has_password ? "密码已配置" : "未配置密码" - } - sx={{ alignSelf: { xs: "flex-start", md: "center" } }} - /> - </Stack> - <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> - <TextField - size="small" - label="Base URL" - value={geoserverForm.gs_base_url} - onChange={(event) => - setGeoserverForm({ - ...geoserverForm, - gs_base_url: event.target.value, - }) - } - fullWidth - /> - <TextField - size="small" - label="管理员账号" - value={geoserverForm.gs_admin_user} - onChange={(event) => - setGeoserverForm({ - ...geoserverForm, - gs_admin_user: event.target.value, - }) - } - fullWidth - /> - <TextField - size="small" - type="password" - label="管理员密码" - value={geoserverForm.gs_admin_password} - onChange={(event) => - setGeoserverForm({ - ...geoserverForm, - gs_admin_password: event.target.value, - }) - } - fullWidth - /> - </Stack> - <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> - <TextField - size="small" - label="Datastore" - value={geoserverForm.gs_datastore_name} - onChange={(event) => - setGeoserverForm({ - ...geoserverForm, - gs_datastore_name: event.target.value, - }) - } - fullWidth - required - /> - <TextField - size="small" - type="number" - label="SRID" - value={geoserverForm.srid} - onChange={(event) => - setGeoserverForm({ - ...geoserverForm, - srid: Number(event.target.value), - }) - } - sx={{ width: { xs: "100%", md: 180 } }} - required - /> - </Stack> - <TextField - size="small" - label="默认范围 JSON" - value={geoserverForm.default_extent} - onChange={(event) => - setGeoserverForm({ - ...geoserverForm, - default_extent: event.target.value, - }) - } - fullWidth - multiline - minRows={4} - placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' - /> - <Stack direction="row" justifyContent="flex-end"> - <Button type="submit" variant="contained" startIcon={<SaveIcon />}> - 保存 GeoServer - </Button> - </Stack> - </Stack> - </Paper> - )} - </Stack> - )} - </Box> </Paper> <Dialog -- 2.54.0 From 6ff88865242298946080b415304e1d01218b4410 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 12 Jun 2026 15:49:43 +0800 Subject: [PATCH 201/281] feat(audit): add audit log page --- src/app/(main)/audit-logs/page.tsx | 5 + src/app/_refine_context.tsx | 9 + src/components/audit/AuditLogPanel.tsx | 1055 ++++++++++++++++++++++++ 3 files changed, 1069 insertions(+) create mode 100644 src/app/(main)/audit-logs/page.tsx create mode 100644 src/components/audit/AuditLogPanel.tsx diff --git a/src/app/(main)/audit-logs/page.tsx b/src/app/(main)/audit-logs/page.tsx new file mode 100644 index 0000000..a6e3cb1 --- /dev/null +++ b/src/app/(main)/audit-logs/page.tsx @@ -0,0 +1,5 @@ +import { AuditLogPanel } from "@/components/audit/AuditLogPanel"; + +export default function AuditLogsPage() { + return <AuditLogPanel />; +} diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx index 29511e2..3b76364 100644 --- a/src/app/_refine_context.tsx +++ b/src/app/_refine_context.tsx @@ -26,6 +26,7 @@ import { AiOutlineSecurityScan } from "react-icons/ai"; import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { ManageAccounts as ManageAccountsIcon, + FactCheck as FactCheckIcon, MyLocation as MyLocationIcon, Search as SearchIcon, } from "@mui/icons-material"; @@ -270,6 +271,14 @@ const App = (props: React.PropsWithChildren<AppProps>) => { label: "系统管理", }, }, + { + name: "审计日志", + list: "/audit-logs", + meta: { + icon: <FactCheckIcon className="w-6 h-6" />, + label: "审计日志", + }, + }, ] : []), ]} diff --git a/src/components/audit/AuditLogPanel.tsx b/src/components/audit/AuditLogPanel.tsx new file mode 100644 index 0000000..d82c5e7 --- /dev/null +++ b/src/components/audit/AuditLogPanel.tsx @@ -0,0 +1,1055 @@ +"use client"; + +import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + alpha, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputLabel, + LinearProgress, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import "dayjs/locale/zh-cn"; +import dayjs from "dayjs"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import { + AdminPanelSettings as AdminPanelSettingsIcon, + Close as CloseIcon, + Download as DownloadIcon, + FactCheck as FactCheckIcon, + FilterAltOff as FilterAltOffIcon, + InfoOutlined as InfoOutlinedIcon, + Refresh as RefreshIcon, + Search as SearchIcon, + Visibility as VisibilityIcon, +} from "@mui/icons-material"; +import { config } from "@config/config"; +import { apiFetch } from "@/lib/apiFetch"; + +type AuditLog = { + id: string; + user_id: string | null; + project_id: string | null; + action: string; + resource_type: string | null; + resource_id: string | null; + ip_address: string | null; + request_method: string | null; + request_path: string | null; + request_data: Record<string, unknown> | null; + response_status: number | null; + timestamp: string; +}; + +type MetadataUser = { + id: string; + username: string; + email: string; +}; + +type AdminProject = { + project_id: string; + name: string; + code: string; +}; + +type AuditFilters = { + user_id: string; + project_id: string; + action: string; + resource_type: string; + status: AuditStatusFilter; + start_time: string; + end_time: string; +}; + +type AuditStatusFilter = + | "all" + | "success" + | "redirect" + | "client_error" + | "server_error" + | "no_status"; + +const defaultFilters: AuditFilters = { + user_id: "", + project_id: "", + action: "", + resource_type: "", + status: "all", + start_time: "", + end_time: "", +}; + +const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ + { value: "all", label: "全部状态" }, + { value: "success", label: "成功 2xx" }, + { value: "redirect", label: "重定向 3xx" }, + { value: "client_error", label: "客户端错误 4xx" }, + { value: "server_error", label: "服务端错误 5xx" }, + { value: "no_status", label: "无响应状态" }, +]; + +const cardSx = { + borderRadius: 2, + borderColor: "divider", + boxShadow: "0 10px 30px rgba(15, 23, 42, 0.06)", +}; + +const tableSx = { + minWidth: 1080, + "& .MuiTableCell-head": { + bgcolor: "action.hover", + color: "text.secondary", + fontSize: 12, + fontWeight: 700, + letterSpacing: 0, + }, + "& .MuiTableRow-root:last-child .MuiTableCell-body": { + borderBottom: 0, + }, +}; + +const selectMenuProps = { + disableScrollLock: true, +}; + +const readErrorText = async (response: Response) => { + const text = await response.text(); + return text || `HTTP ${response.status}`; +}; + +const normalizeDateTime = (value: string) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString(); +}; + +const formatDateTime = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(date); +}; + +const getStatusMeta = (status: number | null) => { + if (status == null) { + return { label: "无状态", color: "default" as const }; + } + if (status >= 200 && status < 300) { + return { label: String(status), color: "success" as const }; + } + if (status >= 300 && status < 400) { + return { label: String(status), color: "info" as const }; + } + if (status >= 400 && status < 500) { + return { label: String(status), color: "warning" as const }; + } + if (status >= 500) { + return { label: String(status), color: "error" as const }; + } + return { label: String(status), color: "default" as const }; +}; + +const matchesStatusFilter = (log: AuditLog, filter: AuditStatusFilter) => { + const status = log.response_status; + if (filter === "all") return true; + if (filter === "no_status") return status == null; + if (status == null) return false; + if (filter === "success") return status >= 200 && status < 300; + if (filter === "redirect") return status >= 300 && status < 400; + if (filter === "client_error") return status >= 400 && status < 500; + if (filter === "server_error") return status >= 500; + return true; +}; + +const appendCsvValue = (value: unknown) => { + const text = + typeof value === "string" + ? value + : value == null + ? "" + : JSON.stringify(value); + return `"${text.replaceAll('"', '""')}"`; +}; + +const buildCsv = (logs: AuditLog[], userMap: Map<string, MetadataUser>, projectMap: Map<string, AdminProject>) => { + const header = [ + "时间", + "操作者", + "用户ID", + "项目", + "项目ID", + "动作", + "资源类型", + "资源ID", + "状态", + "方法", + "路径", + "IP", + "请求数据", + ]; + const rows = logs.map((log) => [ + formatDateTime(log.timestamp), + log.user_id ? userMap.get(log.user_id)?.username ?? "" : "", + log.user_id ?? "", + log.project_id ? projectMap.get(log.project_id)?.name ?? "" : "", + log.project_id ?? "", + log.action, + log.resource_type ?? "", + log.resource_id ?? "", + log.response_status ?? "", + log.request_method ?? "", + log.request_path ?? "", + log.ip_address ?? "", + log.request_data ?? "", + ]); + + return [header, ...rows] + .map((row) => row.map(appendCsvValue).join(",")) + .join("\n"); +}; + +const buildServerParams = (filters: AuditFilters, skip: number, limit: number) => { + const params = new URLSearchParams(); + if (filters.user_id) params.set("user_id", filters.user_id); + if (filters.project_id) params.set("project_id", filters.project_id); + if (filters.action.trim()) params.set("action", filters.action.trim()); + if (filters.resource_type.trim()) { + params.set("resource_type", filters.resource_type.trim()); + } + const startTime = normalizeDateTime(filters.start_time); + const endTime = normalizeDateTime(filters.end_time); + if (startTime) params.set("start_time", startTime); + if (endTime) params.set("end_time", endTime); + params.set("skip", String(skip)); + params.set("limit", String(limit)); + return params; +}; + +const buildCountParams = (filters: AuditFilters) => { + const params = buildServerParams(filters, 0, 1); + params.delete("skip"); + params.delete("limit"); + return params; +}; + +const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => ( + <TableRow> + <TableCell colSpan={colSpan} align="center" sx={{ py: 6 }}> + <Typography color="text.secondary">{label}</Typography> + </TableCell> + </TableRow> +); + +const StatusChip = ({ status }: { status: number | null }) => { + const meta = getStatusMeta(status); + return <Chip size="small" color={meta.color} label={meta.label} variant={status == null ? "outlined" : "filled"} />; +}; + +const DetailLine = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => ( + <Box + sx={{ + minWidth: 0, + p: 1.5, + borderRadius: 1.5, + border: 1, + borderColor: "divider", + bgcolor: "background.default", + }} + > + <Typography variant="caption" color="text.secondary"> + {label} + </Typography> + <Box + sx={{ + mt: 0.5, + color: "text.primary", + fontSize: 14, + fontWeight: 600, + lineHeight: 1.6, + overflowWrap: "anywhere", + }} + > + {value || "-"} + </Box> + </Box> +); + +const DetailSection = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( + <Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}> + <Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}> + {title} + </Typography> + <Box + sx={{ + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, + gap: 1.5, + }} + > + {children} + </Box> + </Paper> +); + +export const AuditLogPanel = () => { + const [adminChecked, setAdminChecked] = useState(false); + const [isAuthorized, setIsAuthorized] = useState(false); + const [logs, setLogs] = useState<AuditLog[]>([]); + const [users, setUsers] = useState<MetadataUser[]>([]); + const [projects, setProjects] = useState<AdminProject[]>([]); + const [filters, setFilters] = useState<AuditFilters>(defaultFilters); + const [appliedFilters, setAppliedFilters] = useState<AuditFilters>(defaultFilters); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + const [error, setError] = useState<string | null>(null); + const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null); + const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null); + + const userMap = useMemo( + () => new Map(users.map((user) => [user.id, user])), + [users], + ); + const projectMap = useMemo( + () => new Map(projects.map((project) => [project.project_id, project])), + [projects], + ); + const usesClientStatusFilter = appliedFilters.status !== "all"; + + const visibleLogs = useMemo(() => { + if (!usesClientStatusFilter) return logs; + const filtered = logs.filter((log) => matchesStatusFilter(log, appliedFilters.status)); + return filtered.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); + }, [appliedFilters.status, logs, page, rowsPerPage, usesClientStatusFilter]); + + const loadOptions = useCallback(async () => { + const [usersResult, projectsResult] = await Promise.allSettled([ + apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`), + apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`), + ]); + + if (usersResult.status === "fulfilled" && usersResult.value.ok) { + setUsers((await usersResult.value.json()) as MetadataUser[]); + } + if (projectsResult.status === "fulfilled" && projectsResult.value.ok) { + setProjects((await projectsResult.value.json()) as AdminProject[]); + } + }, []); + + const loadLogs = useCallback(async () => { + setLoading(true); + setError(null); + try { + if (appliedFilters.status === "all") { + const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage); + const countParams = buildCountParams(appliedFilters); + const [logsResponse, countResponse] = await Promise.all([ + apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`), + apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`), + ]); + if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse)); + if (!countResponse.ok) throw new Error(await readErrorText(countResponse)); + const countPayload = (await countResponse.json()) as { count?: number }; + setLogs((await logsResponse.json()) as AuditLog[]); + setTotalCount(Number(countPayload.count ?? 0)); + } else { + const params = buildServerParams(appliedFilters, 0, 1000); + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + if (!response.ok) throw new Error(await readErrorText(response)); + const payload = (await response.json()) as AuditLog[]; + setLogs(payload); + setTotalCount(payload.filter((log) => matchesStatusFilter(log, appliedFilters.status)).length); + } + setLastLoadedAt(new Date().toISOString()); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + }, [appliedFilters, page, rowsPerPage]); + + useEffect(() => { + let cancelled = false; + + const checkAdmin = async () => { + try { + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { + projectHeaderMode: "omit", + skipAuthRedirect: true, + }); + if (cancelled) return; + if (!response.ok) { + setIsAuthorized(false); + setAdminChecked(true); + return; + } + const payload = await response.json(); + setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin")); + setAdminChecked(true); + } catch (err) { + if (!cancelled) { + setIsAuthorized(false); + setAdminChecked(true); + setError(String(err)); + } + } + }; + + void checkAdmin(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!adminChecked || !isAuthorized) return; + void loadOptions(); + }, [adminChecked, isAuthorized, loadOptions]); + + useEffect(() => { + if (!adminChecked || !isAuthorized) return; + void loadLogs(); + }, [adminChecked, isAuthorized, loadLogs]); + + const applyFilters = (event: FormEvent) => { + event.preventDefault(); + setPage(0); + setAppliedFilters(filters); + }; + + const resetFilters = () => { + setFilters(defaultFilters); + setAppliedFilters(defaultFilters); + setPage(0); + }; + + const exportLogs = async () => { + setExporting(true); + setError(null); + try { + const params = buildServerParams(appliedFilters, 0, 1000); + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + if (!response.ok) throw new Error(await readErrorText(response)); + const payload = ((await response.json()) as AuditLog[]).filter((log) => + matchesStatusFilter(log, appliedFilters.status), + ); + const csv = buildCsv(payload, userMap, projectMap); + const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const href = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = href; + link.download = `tjwater-audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(href); + } catch (err) { + setError(String(err)); + } finally { + setExporting(false); + } + }; + + return ( + <Box + sx={{ + minHeight: "100%", + overflow: "auto", + bgcolor: "background.default", + p: { xs: 2, md: 3 }, + }} + > + <Stack spacing={2.5} sx={{ maxWidth: 1440, mx: "auto" }}> + <Paper + variant="outlined" + sx={(theme) => ({ + ...cardSx, + p: { xs: 2, md: 3 }, + bgcolor: + theme.palette.mode === "dark" + ? alpha(theme.palette.info.main, 0.1) + : alpha(theme.palette.info.main, 0.04), + })} + > + <Stack + direction={{ xs: "column", md: "row" }} + spacing={2} + alignItems={{ xs: "stretch", md: "center" }} + justifyContent="space-between" + > + <Stack direction="row" spacing={1.5} alignItems="center"> + <Box + sx={(theme) => ({ + width: 48, + height: 48, + borderRadius: 2, + display: "grid", + placeItems: "center", + color: "info.main", + bgcolor: alpha(theme.palette.info.main, 0.12), + })} + > + <FactCheckIcon /> + </Box> + <Box sx={{ minWidth: 0 }}> + <Typography variant="h5" fontWeight={800}> + 审计日志 + </Typography> + <Typography variant="body2" color="text.secondary"> + 管理员审计查询 + </Typography> + </Box> + </Stack> + {adminChecked && isAuthorized && ( + <Chip + color="success" + icon={<AdminPanelSettingsIcon />} + label="管理员权限已验证" + sx={{ alignSelf: { xs: "flex-start", md: "center" } }} + /> + )} + </Stack> + </Paper> + + {!adminChecked && ( + <Paper variant="outlined" sx={{ ...cardSx, p: 5 }}> + <Stack alignItems="center" spacing={2}> + <CircularProgress size={28} /> + <Typography color="text.secondary">正在校验审计权限</Typography> + </Stack> + </Paper> + )} + + {adminChecked && !isAuthorized && ( + <Alert severity="error" sx={{ borderRadius: 2 }}> + 无审计日志访问权限 + </Alert> + )} + + {error && ( + <Alert severity="error" onClose={() => setError(null)} sx={{ borderRadius: 2 }}> + {error} + </Alert> + )} + + {adminChecked && isAuthorized && ( + <> + <Stack direction={{ xs: "column", md: "row" }} spacing={2}> + <Paper variant="outlined" sx={{ ...cardSx, p: 2, flex: 1 }}> + <Stack spacing={0.5}> + <Typography variant="caption" color="text.secondary"> + 匹配记录 + </Typography> + <Typography variant="h5" fontWeight={800}> + {totalCount} + </Typography> + </Stack> + </Paper> + <Paper variant="outlined" sx={{ ...cardSx, p: 2, flex: 1 }}> + <Stack spacing={0.5}> + <Typography variant="caption" color="text.secondary"> + 最近刷新 + </Typography> + <Typography variant="h6" fontWeight={800}> + {lastLoadedAt ? formatDateTime(lastLoadedAt) : "-"} + </Typography> + </Stack> + </Paper> + </Stack> + + <Paper + component="form" + variant="outlined" + sx={{ ...cardSx, p: 2 }} + onSubmit={applyFilters} + > + <Stack spacing={2}> + <Stack + direction={{ xs: "column", md: "row" }} + spacing={1.5} + alignItems={{ xs: "stretch", md: "center" }} + > + <FormControl size="small" sx={{ minWidth: { xs: "100%", md: 220 } }}> + <InputLabel shrink>操作者</InputLabel> + <Select + label="操作者" + value={filters.user_id} + MenuProps={selectMenuProps} + displayEmpty + notched + renderValue={(value) => { + if (!value) return "全部操作者"; + const user = userMap.get(String(value)); + return user ? `${user.username} / ${user.email}` : String(value); + }} + onChange={(event) => + setFilters((current) => ({ ...current, user_id: event.target.value })) + } + > + <MenuItem value="">全部操作者</MenuItem> + {users.map((user) => ( + <MenuItem key={user.id} value={user.id}> + {user.username} / {user.email} + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl size="small" sx={{ minWidth: { xs: "100%", md: 220 } }}> + <InputLabel shrink>项目</InputLabel> + <Select + label="项目" + value={filters.project_id} + MenuProps={selectMenuProps} + displayEmpty + notched + renderValue={(value) => { + if (!value) return "全部项目"; + const project = projectMap.get(String(value)); + return project ? `${project.name} / ${project.code}` : String(value); + }} + onChange={(event) => + setFilters((current) => ({ ...current, project_id: event.target.value })) + } + > + <MenuItem value="">全部项目</MenuItem> + {projects.map((project) => ( + <MenuItem key={project.project_id} value={project.project_id}> + {project.name} / {project.code} + </MenuItem> + ))} + </Select> + </FormControl> + <TextField + size="small" + label="动作" + value={filters.action} + onChange={(event) => + setFilters((current) => ({ ...current, action: event.target.value })) + } + sx={{ minWidth: { xs: "100%", md: 180 } }} + /> + <TextField + size="small" + label="资源" + value={filters.resource_type} + onChange={(event) => + setFilters((current) => ({ ...current, resource_type: event.target.value })) + } + sx={{ minWidth: { xs: "100%", md: 180 } }} + /> + </Stack> + <Stack + direction={{ xs: "column", md: "row" }} + spacing={1.5} + alignItems={{ xs: "stretch", md: "center" }} + > + <LocalizationProvider + dateAdapter={AdapterDayjs} + adapterLocale="zh-cn" + localeText={ + pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText + } + > + <FormControl size="small" sx={{ minWidth: { xs: "100%", md: 180 } }}> + <InputLabel>状态</InputLabel> + <Select + label="状态" + value={filters.status} + MenuProps={selectMenuProps} + onChange={(event) => + setFilters((current) => ({ + ...current, + status: event.target.value as AuditStatusFilter, + })) + } + > + {statusFilterOptions.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + </FormControl> + <DateTimePicker + label="开始时间" + value={filters.start_time ? dayjs(filters.start_time) : null} + onChange={(value) => + setFilters((current) => ({ + ...current, + start_time: value?.isValid() ? value.toISOString() : "", + })) + } + maxDateTime={filters.end_time ? dayjs(filters.end_time) : undefined} + slotProps={{ + textField: { + size: "small", + sx: { minWidth: { xs: "100%", md: 220 } }, + }, + }} + /> + <DateTimePicker + label="结束时间" + value={filters.end_time ? dayjs(filters.end_time) : null} + onChange={(value) => + setFilters((current) => ({ + ...current, + end_time: value?.isValid() ? value.toISOString() : "", + })) + } + minDateTime={filters.start_time ? dayjs(filters.start_time) : undefined} + slotProps={{ + textField: { + size: "small", + sx: { minWidth: { xs: "100%", md: 220 } }, + }, + }} + /> + </LocalizationProvider> + <Box sx={{ flex: 1 }} /> + <Stack direction="row" spacing={1} justifyContent="flex-end"> + <Button + type="button" + variant="outlined" + startIcon={<FilterAltOffIcon />} + onClick={resetFilters} + > + 重置 + </Button> + <Button + type="button" + variant="outlined" + startIcon={<RefreshIcon />} + onClick={loadLogs} + disabled={loading} + > + 刷新 + </Button> + <Button type="submit" variant="contained" startIcon={<SearchIcon />}> + 查询 + </Button> + </Stack> + </Stack> + </Stack> + </Paper> + + {usesClientStatusFilter && ( + <Alert severity="info" icon={<InfoOutlinedIcon />} sx={{ borderRadius: 2 }}> + 状态筛选基于当前查询最多 1000 条记录处理。 + </Alert> + )} + + <Paper variant="outlined" sx={{ ...cardSx, overflow: "hidden" }}> + <Stack + direction={{ xs: "column", md: "row" }} + spacing={1} + alignItems={{ xs: "stretch", md: "center" }} + justifyContent="space-between" + sx={{ p: 2, borderBottom: 1, borderColor: "divider" }} + > + <Box> + <Typography variant="subtitle1" fontWeight={800}> + 查询结果 + </Typography> + <Typography variant="body2" color="text.secondary"> + {totalCount} 条匹配记录 + </Typography> + </Box> + <Button + variant="outlined" + startIcon={exporting ? <CircularProgress size={16} /> : <DownloadIcon />} + onClick={exportLogs} + disabled={exporting || loading} + sx={{ minWidth: 116 }} + > + 导出 CSV + </Button> + </Stack> + <Box sx={{ height: 3 }}> + {loading && <LinearProgress sx={{ height: 3 }} />} + </Box> + <TableContainer> + <Table size="small" sx={tableSx}> + <TableHead> + <TableRow> + <TableCell>时间</TableCell> + <TableCell>操作者</TableCell> + <TableCell>动作</TableCell> + <TableCell>资源</TableCell> + <TableCell>状态</TableCell> + <TableCell>请求</TableCell> + <TableCell>IP</TableCell> + <TableCell align="right">详情</TableCell> + </TableRow> + </TableHead> + <TableBody> + {visibleLogs.length === 0 && ( + <EmptyRow + colSpan={8} + label={loading ? "正在加载审计日志" : "暂无审计日志"} + /> + )} + {visibleLogs.map((log) => { + const user = log.user_id ? userMap.get(log.user_id) : null; + return ( + <TableRow key={log.id} hover> + <TableCell sx={{ whiteSpace: "nowrap" }}> + {formatDateTime(log.timestamp)} + </TableCell> + <TableCell> + <Stack spacing={0.25}> + <Typography variant="body2" fontWeight={700}> + {user?.username ?? "未知用户"} + </Typography> + <Typography variant="caption" color="text.secondary"> + {log.user_id ?? "-"} + </Typography> + </Stack> + </TableCell> + <TableCell> + <Typography variant="body2" fontWeight={700}> + {log.action} + </Typography> + </TableCell> + <TableCell> + <Stack spacing={0.25}> + <Typography variant="body2"> + {log.resource_type ?? "-"} + </Typography> + <Typography variant="caption" color="text.secondary"> + {log.resource_id ?? "-"} + </Typography> + </Stack> + </TableCell> + <TableCell> + <StatusChip status={log.response_status} /> + </TableCell> + <TableCell> + <Stack spacing={0.25} sx={{ maxWidth: 280 }}> + <Typography variant="body2" fontWeight={700}> + {log.request_method ?? "-"} + </Typography> + <Typography variant="caption" color="text.secondary" noWrap> + {log.request_path ?? "-"} + </Typography> + </Stack> + </TableCell> + <TableCell>{log.ip_address ?? "-"}</TableCell> + <TableCell align="right"> + <Tooltip title="查看详情"> + <IconButton size="small" onClick={() => setSelectedLog(log)}> + <VisibilityIcon fontSize="small" /> + </IconButton> + </Tooltip> + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + </TableContainer> + <TablePagination + component="div" + count={totalCount} + page={page} + onPageChange={(_, nextPage) => setPage(nextPage)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(event) => { + setRowsPerPage(Number(event.target.value)); + setPage(0); + }} + SelectProps={{ MenuProps: selectMenuProps }} + rowsPerPageOptions={[10, 25, 50, 100]} + labelRowsPerPage="每页行数" + /> + </Paper> + </> + )} + </Stack> + + <Dialog + open={Boolean(selectedLog)} + onClose={() => setSelectedLog(null)} + maxWidth="md" + fullWidth + disableScrollLock + transitionDuration={{ enter: 120, exit: 0 }} + > + <DialogTitle sx={{ px: 3, py: 2.25, pr: 7 }}> + <Stack spacing={0.25}> + <Typography variant="h6" fontWeight={800}> + 审计详情 + </Typography> + <Typography variant="body2" color="text.secondary"> + {selectedLog ? formatDateTime(selectedLog.timestamp) : ""} + </Typography> + </Stack> + <IconButton + aria-label="关闭" + onClick={() => setSelectedLog(null)} + sx={{ position: "absolute", right: 12, top: 12 }} + > + <CloseIcon /> + </IconButton> + </DialogTitle> + <DialogContent dividers sx={{ bgcolor: "background.default", p: 2.5 }}> + {selectedLog && ( + <Stack spacing={2.5}> + <Paper + variant="outlined" + sx={(theme) => ({ + p: 2, + borderRadius: 2, + bgcolor: alpha(theme.palette.info.main, 0.05), + borderColor: alpha(theme.palette.info.main, 0.2), + })} + > + <Stack + direction={{ xs: "column", sm: "row" }} + spacing={1.5} + alignItems={{ xs: "flex-start", sm: "center" }} + justifyContent="space-between" + > + <Stack spacing={0.5} sx={{ minWidth: 0 }}> + <Typography variant="caption" color="text.secondary"> + 审计动作 + </Typography> + <Typography variant="h6" fontWeight={800} sx={{ overflowWrap: "anywhere" }}> + {selectedLog.action} + </Typography> + <Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere" }}> + {selectedLog.request_method ?? "-"} {selectedLog.request_path ?? "-"} + </Typography> + </Stack> + <StatusChip status={selectedLog.response_status} /> + </Stack> + </Paper> + + <DetailSection title="审计主体"> + <DetailLine + label="操作者" + value={ + selectedLog.user_id + ? userMap.get(selectedLog.user_id)?.username ?? selectedLog.user_id + : "-" + } + /> + <DetailLine label="用户 ID" value={selectedLog.user_id ?? "-"} /> + <DetailLine + label="项目" + value={ + selectedLog.project_id + ? projectMap.get(selectedLog.project_id)?.name ?? selectedLog.project_id + : "-" + } + /> + <DetailLine label="项目 ID" value={selectedLog.project_id ?? "-"} /> + <DetailLine label="来源 IP" value={selectedLog.ip_address ?? "-"} /> + <DetailLine label="审计 ID" value={selectedLog.id} /> + </DetailSection> + + <DetailSection title="资源与请求"> + <DetailLine label="资源类型" value={selectedLog.resource_type ?? "-"} /> + <DetailLine label="资源 ID" value={selectedLog.resource_id ?? "-"} /> + <DetailLine label="请求方法" value={selectedLog.request_method ?? "-"} /> + <DetailLine label="请求路径" value={selectedLog.request_path ?? "-"} /> + <DetailLine + label="响应状态" + value={<StatusChip status={selectedLog.response_status} />} + /> + <DetailLine label="记录时间" value={formatDateTime(selectedLog.timestamp)} /> + </DetailSection> + + <Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}> + <Stack + direction="row" + alignItems="center" + justifyContent="space-between" + sx={{ mb: 1.5 }} + > + <Typography variant="subtitle2" fontWeight={800}> + 请求数据 + </Typography> + <Chip + size="small" + variant="outlined" + label={selectedLog.request_data ? "JSON" : "空对象"} + /> + </Stack> + <Box + component="pre" + sx={{ + p: 2, + borderRadius: 2, + bgcolor: "action.hover", + overflow: "auto", + fontSize: 13, + lineHeight: 1.6, + maxHeight: 320, + m: 0, + }} + > + {selectedLog.request_data + ? JSON.stringify(selectedLog.request_data, null, 2) + : "{}"} + </Box> + </Paper> + </Stack> + )} + </DialogContent> + <DialogActions sx={{ px: 3, py: 2 }}> + <Button variant="contained" onClick={() => setSelectedLog(null)}> + 关闭 + </Button> + </DialogActions> + </Dialog> + </Box> + ); +}; -- 2.54.0 From 0dea655f68f23bcb821cc0b37fed202d8d8be984 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Sat, 13 Jun 2026 13:07:16 +0800 Subject: [PATCH 202/281] refactor(frontend): normalize naming conventions --- AGENTS.md | 2 +- FRONTEND_NAMING_AUDIT.md | 56 +++++++++++++++++++ ...{_refine_context.tsx => RefineContext.tsx} | 0 src/app/layout.tsx | 2 +- src/components/chat/AgentTurn.tsx | 2 +- src/components/chat/GlobalChatbox.tsx | 6 +- ...atbox.parts.tsx => GlobalChatboxParts.tsx} | 0 src/components/chat/chatStorage.ts | 2 +- ...ils.test.ts => globalChatboxUtils.test.ts} | 2 +- ...Chatbox.utils.ts => globalChatboxUtils.ts} | 0 ...Chatbox.voice.ts => globalChatboxVoice.ts} | 0 .../chat/hooks/agentChatSessionState.ts | 2 +- .../chat/hooks/useAgentChatSession.test.tsx | 2 - .../chat/hooks/useAgentChatSession.ts | 2 +- .../BurstDetection/AnalysisParameters.tsx | 2 +- .../BurstLocation/AnalysisParameters.tsx | 2 +- .../BurstSimulation/AnalysisParameters.tsx | 2 +- .../olmap/BurstSimulation/SchemeQuery.tsx | 2 +- .../olmap/BurstSimulation/ValveIsolation.tsx | 2 +- .../AnalysisParameters.tsx | 2 +- .../ContaminantSimulation/SchemeQuery.tsx | 2 +- .../FlushingAnalysis/AnalysisParameters.tsx | 2 +- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 2 +- .../OptimizationParameters.tsx | 2 +- .../SchemeQuery.tsx | 2 +- .../olmap/core/Controls/Timeline.tsx | 2 +- .../olmap/core/Controls/styleEditorUtils.ts | 2 +- .../olmap/core/Controls/useStyleEditor.ts | 2 +- src/contexts/ProjectContext.tsx | 4 +- ...ssification.ts => breaksClassification.ts} | 10 ++-- 30 files changed, 87 insertions(+), 33 deletions(-) create mode 100644 FRONTEND_NAMING_AUDIT.md rename src/app/{_refine_context.tsx => RefineContext.tsx} (100%) rename src/components/chat/{GlobalChatbox.parts.tsx => GlobalChatboxParts.tsx} (100%) rename src/components/chat/{GlobalChatbox.utils.test.ts => globalChatboxUtils.test.ts} (94%) rename src/components/chat/{GlobalChatbox.utils.ts => globalChatboxUtils.ts} (100%) rename src/components/chat/{GlobalChatbox.voice.ts => globalChatboxVoice.ts} (100%) delete mode 100644 src/components/chat/hooks/useAgentChatSession.test.tsx rename src/utils/{breaks_classification.ts => breaksClassification.ts} (92%) diff --git a/AGENTS.md b/AGENTS.md index a0cfc22..96c9bcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ npm run start ## Coding Style & Naming Conventions -Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for components, `camelCase` for variables/functions, and descriptive feature-oriented filenames. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused. +Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused. ## Testing Guidelines diff --git a/FRONTEND_NAMING_AUDIT.md b/FRONTEND_NAMING_AUDIT.md new file mode 100644 index 0000000..7aabb4f --- /dev/null +++ b/FRONTEND_NAMING_AUDIT.md @@ -0,0 +1,56 @@ +# Frontend Naming Audit + +DOC-004 audit for the internal `TJWaterFrontend_Refine` application. + +## Local frontend naming + +- Next.js route directories under `src/app` are already `kebab-case`: `audit-logs`, `health-risk-analysis`, `hydraulic-simulation`, `monitoring-place-optimization`, `network-simulation`, `scada-data-cleaning`, and `system-admin`. +- Route groups and dynamic segments keep Next.js syntax: `(main)` and `[...nextauth]`. +- Local ordinary module filenames now use `camelCase`, including `src/utils/breaksClassification.ts`, `src/components/chat/globalChatboxUtils.ts`, and `src/components/chat/globalChatboxVoice.ts`. +- React component files remain `PascalCase.tsx`, including `src/app/RefineContext.tsx`, `src/components/chat/GlobalChatboxParts.tsx`, and domain directories under `src/components/olmap`. + +## API boundary naming + +Frontend request and response boundary fields intentionally keep backend/Agent wire names. Examples include `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `session_id`, `request_id`, and `keep_message_count`. + +Current direct API calls mostly use new `kebab-case` URL paths, including: + +- `/api/v1/admin/projects` +- `/api/v1/audit/logs` +- `/api/v1/projects/open` +- `/api/v1/project-info` +- `/api/v1/schemes` +- `/api/v1/sensor-placement-schemes` +- `/api/v1/burst-analysis` +- `/api/v1/valve-isolation-analysis` +- `/api/v1/flushing-analysis` +- `/api/v1/contaminant-simulation` +- `/api/v1/simulations/run-by-date` +- `/api/v1/burst-detection/detect` +- `/api/v1/burst-location/locate` +- `/api/v1/scada/by-ids-field-time-range` +- `/api/v1/composite/clean-scada` +- `/api/v1/agent/chat/render-ref/{render_ref}` + +## Legacy URL inventory + +The frontend no longer calls these active legacy URLs directly. The backend still exposes them as deprecated compatibility aliases: + +| Current frontend URL | Files | Suggested target | +| --- | --- | --- | +| `/api/v1/openproject/` | `src/contexts/ProjectContext.tsx` | `/api/v1/projects/open` or `/api/v1/project/open` | +| `/api/v1/project_info/` | `src/contexts/ProjectContext.tsx` | `/api/v1/project-info` | +| `/api/v1/getallschemes/` | burst, burst simulation, contaminant, flushing scheme query components | `/api/v1/schemes` | +| `/api/v1/getallsensorplacements/` | `src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx` | `/api/v1/sensor-placement-schemes` | +| `/api/v1/sensorplacementscheme/create` | `src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx` | `/api/v1/sensor-placement-schemes` | +| `/api/v1/burst_analysis/` | `src/components/olmap/BurstSimulation/AnalysisParameters.tsx` | `/api/v1/burst-analysis` | +| `/api/v1/valve_isolation_analysis/` | `src/components/olmap/BurstSimulation/ValveIsolation.tsx` | `/api/v1/valve-isolation-analysis` | +| `/api/v1/flushing_analysis/` | `src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx` | `/api/v1/flushing-analysis` | +| `/api/v1/contaminant_simulation/` | `src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx` | `/api/v1/contaminant-simulation` | +| `/api/v1/runsimulationmanuallybydate/` | `src/components/olmap/core/Controls/Timeline.tsx` | `/api/v1/simulations/run-by-date` | + +Already migrated frontend code leaves comments showing older pre-`/api/v1` URLs in `src/components/olmap/core/Controls/Toolbar.tsx`; those comments are historical only and are not active calls. + +## Follow-up + +Continue tracking broad passive legacy backend routes under the shared legacy API compatibility strategy. diff --git a/src/app/_refine_context.tsx b/src/app/RefineContext.tsx similarity index 100% rename from src/app/_refine_context.tsx rename to src/app/RefineContext.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 292a454..9cfb22c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; import React, { Suspense } from "react"; -import { RefineContext } from "./_refine_context"; +import { RefineContext } from "./RefineContext"; import { META_DATA } from "@config/config"; export const metadata: Metadata = META_DATA; diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 4f2a1f8..54b10aa 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -23,7 +23,7 @@ import { type ContentSegment, } from "./chatMessageSections"; import type { Message, SpeechState } from "./GlobalChatbox.types"; -import { stripMarkdown } from "./GlobalChatbox.utils"; +import { stripMarkdown } from "./globalChatboxUtils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index ce53b76..eeaae1c 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -17,10 +17,10 @@ import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentWorkspace } from "./AgentWorkspace"; -import { Blob } from "./GlobalChatbox.parts"; +import { Blob } from "./GlobalChatboxParts"; import type { Props } from "./GlobalChatbox.types"; -import { PRESET_PROMPTS } from "./GlobalChatbox.utils"; -import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"; +import { PRESET_PROMPTS } from "./globalChatboxUtils"; +import { useSpeechRecognition, useSpeechSynthesis } from "./globalChatboxVoice"; import { useAgentChatSession } from "./hooks/useAgentChatSession"; import { useAgentToolActions } from "./hooks/useAgentToolActions"; diff --git a/src/components/chat/GlobalChatbox.parts.tsx b/src/components/chat/GlobalChatboxParts.tsx similarity index 100% rename from src/components/chat/GlobalChatbox.parts.tsx rename to src/components/chat/GlobalChatboxParts.tsx diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 675bb0a..c9b5c86 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -6,7 +6,7 @@ import type { LoadedChatState, Message, } from "./GlobalChatbox.types"; -import { cloneMessages } from "./GlobalChatbox.utils"; +import { cloneMessages } from "./globalChatboxUtils"; type BackendSessionPayload = { id?: string; diff --git a/src/components/chat/GlobalChatbox.utils.test.ts b/src/components/chat/globalChatboxUtils.test.ts similarity index 94% rename from src/components/chat/GlobalChatbox.utils.test.ts rename to src/components/chat/globalChatboxUtils.test.ts index 8e89124..c530ab4 100644 --- a/src/components/chat/GlobalChatbox.utils.test.ts +++ b/src/components/chat/globalChatboxUtils.test.ts @@ -1,4 +1,4 @@ -import { cloneMessage } from "./GlobalChatbox.utils"; +import { cloneMessage } from "./globalChatboxUtils"; import type { Message } from "./GlobalChatbox.types"; describe("cloneMessage", () => { diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/globalChatboxUtils.ts similarity index 100% rename from src/components/chat/GlobalChatbox.utils.ts rename to src/components/chat/globalChatboxUtils.ts diff --git a/src/components/chat/GlobalChatbox.voice.ts b/src/components/chat/globalChatboxVoice.ts similarity index 100% rename from src/components/chat/GlobalChatbox.voice.ts rename to src/components/chat/globalChatboxVoice.ts diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts index 7393b4f..d1edf76 100644 --- a/src/components/chat/hooks/agentChatSessionState.ts +++ b/src/components/chat/hooks/agentChatSessionState.ts @@ -9,7 +9,7 @@ import type { ChatProgress, Message, } from "../GlobalChatbox.types"; -import { createId } from "../GlobalChatbox.utils"; +import { createId } from "../globalChatboxUtils"; export const upsertProgress = ( progress: ChatProgress[] | undefined, diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx deleted file mode 100644 index 55c7215..0000000 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Tests for useAgentChatSession are split by behavior boundary. -// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx. diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index dbc45c5..71a74e7 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream"; import type { PermissionReply, StreamEvent } from "@/lib/chatStream"; import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types"; -import { cloneMessages } from "../GlobalChatbox.utils"; +import { cloneMessages } from "../globalChatboxUtils"; import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage"; import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index d7c763c..2b0efba 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -73,7 +73,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setSchemeLoading(true); try { - const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, { + const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params: { network: NETWORK_NAME }, }); const burstSchemes = (response.data as SchemeItem[]).filter( diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index d721187..7223617 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -80,7 +80,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setSchemeLoading(true); try { - const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, { + const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params: { network: NETWORK_NAME }, }); const burstSchemes = (response.data as SchemeItem[]).filter( diff --git a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx index b39756a..c2b6fe5 100644 --- a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx @@ -280,7 +280,7 @@ const AnalysisParameters: React.FC = () => { }; try { - await api.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, { + await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, { params, paramsSerializer: { indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index 22915e6..81087c1 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -110,7 +110,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { const response = await api.get( - `${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`, + `${config.BACKEND_URL}/api/v1/schemes?network=${network}`, ); let filteredResults = response.data; diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index b4985a8..3e58e18 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -271,7 +271,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ params.disabled_valves = disabled; } const response = await api.get( - `${config.BACKEND_URL}/api/v1/valve_isolation_analysis/`, + `${config.BACKEND_URL}/api/v1/valve-isolation-analysis`, { params, paramsSerializer: { diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index 66f95ea..d47deba 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -189,7 +189,7 @@ const AnalysisParameters: React.FC = () => { scheme_name: schemeName, }; - await api.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, { + await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, { params, }); diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index faec59a..c4eca7b 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -181,7 +181,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { const response = await api.get( - `${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`, + `${config.BACKEND_URL}/api/v1/schemes?network=${network}`, ); let filteredResults = response.data; diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 9dbeb1b..fd5a2f9 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -242,7 +242,7 @@ const AnalysisParameters: React.FC = () => { // but axios usually handles array as valves[]=1&valves[]=2 // FastAPI default expects repeated query params. - const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing_analysis/`, { + const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, { params, // Ensure arrays are sent as repeated keys: valves=1&valves=2 paramsSerializer: { diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index fc56bf8..fb1aea3 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -222,7 +222,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { const response = await api.get( - `${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`, + `${config.BACKEND_URL}/api/v1/schemes?network=${network}`, ); let filteredResults = response.data; diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index 3e75c1a..962ddb2 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -94,7 +94,7 @@ const OptimizationParameters: React.FC = () => { try { // 发送优化请求 const response = await api.post( - `${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`, + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes`, null, { params: { diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index da9899c..cab5d67 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -149,7 +149,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { const response = await api.get( - `${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`, + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`, ); let filteredResults = response.data; diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index dcf48de..31ad733 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -643,7 +643,7 @@ const Timeline: React.FC<TimelineProps> = ({ }; const response = await apiFetch( - `${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`, + `${config.BACKEND_URL}/api/v1/simulations/run-by-date`, { method: "POST", headers: { diff --git a/src/components/olmap/core/Controls/styleEditorUtils.ts b/src/components/olmap/core/Controls/styleEditorUtils.ts index 4e6f24c..0baf15b 100644 --- a/src/components/olmap/core/Controls/styleEditorUtils.ts +++ b/src/components/olmap/core/Controls/styleEditorUtils.ts @@ -1,6 +1,6 @@ import { FlatStyleLike } from "ol/style/flat"; -import { calculateClassification } from "@utils/breaks_classification"; +import { calculateClassification } from "@utils/breaksClassification"; import { parseColor } from "@utils/parseColor"; import { diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index d317cd3..efdd9b4 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -31,7 +31,7 @@ import { StyleEditorStateProps, } from "./styleEditorTypes"; import { LegendStyleConfig } from "./StyleLegend"; -import { calculateClassification } from "@utils/breaks_classification"; +import { calculateClassification } from "@utils/breaksClassification"; const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5]; diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index ce5660d..4692e8d 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -53,7 +53,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ try { // Open project backend (simulation model) const openResponse = await apiFetch( - `${config.BACKEND_URL}/api/v1/openproject/?network=${net}`, + `${config.BACKEND_URL}/api/v1/projects/open?network=${net}`, { method: "POST", }, @@ -64,7 +64,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ // Fetch project metadata const infoResponse = await apiFetch( - `${config.BACKEND_URL}/api/v1/project_info/?network=${net}`, + `${config.BACKEND_URL}/api/v1/project-info?network=${net}`, ); if (!infoResponse.ok) { console.warn( diff --git a/src/utils/breaks_classification.ts b/src/utils/breaksClassification.ts similarity index 92% rename from src/utils/breaks_classification.ts rename to src/utils/breaksClassification.ts index ba98b6e..0e71017 100644 --- a/src/utils/breaks_classification.ts +++ b/src/utils/breaksClassification.ts @@ -50,7 +50,7 @@ function variance(data: number[], start: number, end: number): number { return data.slice(start, end + 1).reduce((sum, val) => sum + (val - mean) ** 2, 0); } -export function jenks_breaks_jenkspy(data: number[], nClasses: number): number[] { +function jenksBreaksJenkspy(data: number[], nClasses: number): number[] { if (data.length === 0) return []; if (nClasses >= data.length) return data.slice().sort((a, b) => a - b); @@ -92,13 +92,13 @@ export function jenks_breaks_jenkspy(data: number[], nClasses: number): number[] return breaks; } -export function jenks_with_stratified_sampling( +function jenksWithStratifiedSampling( data: number[], nClasses: number, sampleSize = 10000 ): number[] { if (data.length <= sampleSize) { - return jenks_breaks_jenkspy(data, nClasses); + return jenksBreaksJenkspy(data, nClasses); } const sortedData = data.slice().sort((a, b) => a - b); @@ -112,7 +112,7 @@ export function jenks_with_stratified_sampling( } } - return jenks_breaks_jenkspy(sampledData, nClasses); + return jenksBreaksJenkspy(sampledData, nClasses); } export function calculateClassification( @@ -129,7 +129,7 @@ export function calculateClassification( } if (classificationMethod === "jenks_optimized") { - return jenks_with_stratified_sampling(data, segments); + return jenksWithStratifiedSampling(data, segments); } return []; -- 2.54.0 From cf6386d209572313ad2a38b1059a2381271ca503 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 18:39:49 +0800 Subject: [PATCH 203/281] feat(chat): add Edge TTS playback --- package-lock.json | 62 +- package.json | 1 + src/app/api/tts/edge/route.test.ts | 50 ++ src/app/api/tts/edge/route.ts | 76 ++ src/components/chat/AgentTurn.tsx | 78 +- src/components/chat/AgentWorkspace.tsx | 12 +- src/components/chat/GlobalChatbox.types.ts | 2 +- src/components/chat/globalChatboxVoice.ts | 688 ++++++------------ .../chat/speechStartOptions.test.ts | 31 + src/components/chat/speechStartOptions.ts | 98 +++ 10 files changed, 609 insertions(+), 489 deletions(-) create mode 100644 src/app/api/tts/edge/route.test.ts create mode 100644 src/app/api/tts/edge/route.ts create mode 100644 src/components/chat/speechStartOptions.test.ts create mode 100644 src/components/chat/speechStartOptions.ts diff --git a/package-lock.json b/package-lock.json index 49713fa..f8c2b38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "deck.gl": "^9.1.14", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", + "edge-tts-ts": "^1.0.0", "framer-motion": "^12.38.0", "js-cookie": "^3.0.5", "next": "^16.1.6", @@ -11457,6 +11458,15 @@ "integrity": "sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==", "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -12385,6 +12395,36 @@ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" }, + "node_modules/edge-tts-ts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/edge-tts-ts/-/edge-tts-ts-1.0.0.tgz", + "integrity": "sha512-327gpuN0VjvMsuvbizqabzqMiYpdHb0Slt8/hyf+ridtSkOGN68oogLpFnm8KznunbCnoo3DWMTAn7j6sd3WrA==", + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "isomorphic-ws": "^5.0.0", + "sound-play": "^1.1.0", + "uuid": "^13.0.0", + "ws": "^8.20.0" + }, + "bin": { + "edge-playback": "dist/cli/edge-playback.js", + "edge-tts": "dist/cli/edge-tts.js" + } + }, + "node_modules/edge-tts-ts/node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.227", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", @@ -14904,6 +14944,15 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -19790,6 +19839,12 @@ "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", "license": "MIT" }, + "node_modules/sound-play": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/sound-play/-/sound-play-1.1.0.tgz", + "integrity": "sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -21444,10 +21499,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 8789533..eeff8bf 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "deck.gl": "^9.1.14", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", + "edge-tts-ts": "^1.0.0", "framer-motion": "^12.38.0", "js-cookie": "^3.0.5", "next": "^16.1.6", diff --git a/src/app/api/tts/edge/route.test.ts b/src/app/api/tts/edge/route.test.ts new file mode 100644 index 0000000..5757b4d --- /dev/null +++ b/src/app/api/tts/edge/route.test.ts @@ -0,0 +1,50 @@ +/** + * @jest-environment node + */ + +import { POST } from "./route"; + +const streamMock = jest.fn(); + +jest.mock("edge-tts-ts", () => ({ + Communicate: jest.fn().mockImplementation(() => ({ + stream: streamMock, + })), +})); + +describe("POST /api/tts/edge", () => { + beforeEach(() => { + streamMock.mockReset(); + }); + + it("returns synthesized mp3 audio", async () => { + streamMock.mockImplementation(async function* () { + yield { type: "audio", data: new Uint8Array([1, 2]) }; + yield { type: "SentenceBoundary", offset: 0, duration: 1, text: "测试" }; + yield { type: "audio", data: new Uint8Array([3]) }; + }); + + const response = await POST( + new Request("http://localhost/api/tts/edge", { + method: "POST", + body: JSON.stringify({ text: "测试文本" }), + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("audio/mpeg"); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([1, 2, 3]); + }); + + it("rejects empty text", async () => { + const response = await POST( + new Request("http://localhost/api/tts/edge", { + method: "POST", + body: JSON.stringify({ text: " " }), + }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "text is required" }); + }); +}); diff --git a/src/app/api/tts/edge/route.ts b/src/app/api/tts/edge/route.ts new file mode 100644 index 0000000..52948d7 --- /dev/null +++ b/src/app/api/tts/edge/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { Communicate } from "edge-tts-ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural"; +const MAX_TEXT_LENGTH = 12000; + +type EdgeTtsRequest = { + text?: unknown; + voice?: unknown; +}; + +const jsonError = (message: string, status: number) => + NextResponse.json({ error: message }, { status }); + +export async function POST(request: Request) { + let payload: EdgeTtsRequest; + try { + payload = (await request.json()) as EdgeTtsRequest; + } catch { + return jsonError("Invalid JSON body", 400); + } + + const text = typeof payload.text === "string" ? payload.text.trim() : ""; + if (!text) { + return jsonError("text is required", 400); + } + if (text.length > MAX_TEXT_LENGTH) { + return jsonError(`text must be ${MAX_TEXT_LENGTH} characters or fewer`, 413); + } + + const voice = + typeof payload.voice === "string" && payload.voice.trim() + ? payload.voice.trim() + : DEFAULT_VOICE; + + try { + const communicate = new Communicate(text, { voice }); + const chunks: Uint8Array[] = []; + let byteLength = 0; + + for await (const chunk of communicate.stream()) { + if (chunk.type !== "audio") continue; + chunks.push(chunk.data); + byteLength += chunk.data.byteLength; + } + + if (byteLength === 0) { + return jsonError("Edge TTS returned empty audio", 502); + } + + const audio = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + audio.set(chunk, offset); + offset += chunk.byteLength; + } + + const audioBuffer = audio.buffer.slice( + audio.byteOffset, + audio.byteOffset + audio.byteLength, + ); + + return new Response(audioBuffer, { + headers: { + "Content-Type": "audio/mpeg", + "Cache-Control": "no-store", + }, + }); + } catch (error) { + console.error("[EdgeTTS] Failed to synthesize speech:", error); + return jsonError("Failed to synthesize speech", 502); + } +} diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 54b10aa..0e0965e 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -6,6 +6,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Avatar, Box, + CircularProgress, IconButton, Paper, Stack, @@ -22,8 +23,12 @@ import { parseContentWithToolCalls, type ContentSegment, } from "./chatMessageSections"; -import type { Message, SpeechState } from "./GlobalChatbox.types"; +import type { + Message, + SpeechState, +} from "./GlobalChatbox.types"; import { stripMarkdown } from "./globalChatboxUtils"; +import { findSpeechSelectionStartOffset } from "./speechStartOptions"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; @@ -40,7 +45,11 @@ type AgentTurnProps = { message: Message; isStreaming: boolean; messageSpeechState: SpeechState; - onSpeak: (messageId: string, text: string) => void; + onSpeak: ( + messageId: string, + text: string, + options?: { startOffset?: number }, + ) => void; onPause: () => void; onResume: () => void; onStopSpeech: () => void; @@ -170,6 +179,11 @@ export const AgentTurn = React.memo( const isErrorMessage = Boolean(message.isError); const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const [isHovered, setIsHovered] = React.useState(false); + const answerContentRef = React.useRef<HTMLDivElement | null>(null); + const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{ + offset: number; + preview: string; + } | null>(null); const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", ) ?? false; @@ -185,6 +199,43 @@ export const AgentTurn = React.memo( [isErrorMessage, isUser, message.content], ); const answerContent = parsedAssistantSections?.answer ?? message.content; + const speechText = useMemo( + () => stripMarkdown(answerContent), + [answerContent], + ); + const handleCaptureSpeechSelection = React.useCallback(() => { + const selection = window.getSelection(); + const container = answerContentRef.current; + if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) { + return; + } + + const range = selection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) { + return; + } + + const selectedText = selection.toString(); + const startOffset = findSpeechSelectionStartOffset(speechText, selectedText); + if (startOffset === null) { + setSelectedSpeechStart(null); + return; + } + + const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24); + setSelectedSpeechStart({ + offset: startOffset, + preview: preview.length === 24 ? `${preview}...` : preview, + }); + }, [speechText]); + React.useEffect(() => { + setSelectedSpeechStart(null); + }, [message.id, speechText]); + const handleSpeakFromCurrentStart = () => { + onSpeak(message.id, speechText, { + startOffset: selectedSpeechStart?.offset ?? 0, + }); + }; const contentSegments: ContentSegment[] = useMemo( () => !isUser && !isErrorMessage @@ -333,6 +384,10 @@ export const AgentTurn = React.memo( ) : null} <Box + ref={answerContentRef} + onMouseUp={handleCaptureSpeechSelection} + onKeyUp={handleCaptureSpeechSelection} + onTouchEnd={handleCaptureSpeechSelection} sx={{ p: 1.5, borderRadius: 4, @@ -487,13 +542,28 @@ export const AgentTurn = React.memo( {messageSpeechState === "idle" ? ( <IconButton size="small" - onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} - aria-label="朗读消息" + onClick={handleSpeakFromCurrentStart} + aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"} sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} > <VolumeUpRounded sx={{ fontSize: 16 }} /> </IconButton> ) : null} + {messageSpeechState === "loading" ? ( + <> + <IconButton + size="small" + disabled + aria-label="正在生成语音" + sx={{ color: "primary.main", p: 0.5 }} + > + <CircularProgress size={16} thickness={5} /> + </IconButton> + <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> + <StopRounded sx={{ fontSize: 16 }} /> + </IconButton> + </> + ) : null} {messageSpeechState === "playing" ? ( <> <IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 2833b7e..69c9ee8 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -25,7 +25,11 @@ type AgentWorkspaceProps = { onScrollStateChange?: (isNearBottom: boolean) => void; speakingMessageId: string | null; speechState: SpeechState; - onSpeak: (messageId: string, text: string) => void; + onSpeak: ( + messageId: string, + text: string, + options?: { startOffset?: number }, + ) => void; onPauseSpeech: () => void; onResumeSpeech: () => void; onStopSpeech: () => void; @@ -42,7 +46,11 @@ type TurnListProps = { streamingMessageId: string | null; speakingMessageId: string | null; speechState: SpeechState; - onSpeak: (messageId: string, text: string) => void; + onSpeak: ( + messageId: string, + text: string, + options?: { startOffset?: number }, + ) => void; onPauseSpeech: () => void; onResumeSpeech: () => void; onStopSpeech: () => void; diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 95df637..9bb97fb 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -70,7 +70,7 @@ export type Props = { onClose: () => void; }; -export type SpeechState = "idle" | "playing" | "paused"; +export type SpeechState = "idle" | "loading" | "playing" | "paused"; export type ChatSessionSummary = { id: string; diff --git a/src/components/chat/globalChatboxVoice.ts b/src/components/chat/globalChatboxVoice.ts index 2524c2b..7f41b75 100644 --- a/src/components/chat/globalChatboxVoice.ts +++ b/src/components/chat/globalChatboxVoice.ts @@ -1,32 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import config from "@/config/config"; import type { SpeechState } from "./GlobalChatbox.types"; +import { splitSpeechTextIntoChunks } from "./speechStartOptions"; -type AudioStreamStartResponse = { - stream_id?: string; - audio_url?: string; - status_url?: string; - result_url?: string; - sample_rate?: number; - channels?: number; - error?: string; +type SpeakOptions = { + startOffset?: number; }; -type AudioStreamStatusResponse = { - state?: "starting" | "running" | "done" | "failed" | "closed"; - ready?: boolean; - failed?: boolean; - closed?: boolean; - status_text?: string; - error?: string; -}; - -type AudioStreamResultResponse = { - run_status?: string; - error?: string; -}; - -// WebKit Speech Recognition compatibility interface SpeechRecognitionEvent extends Event { readonly resultIndex: number; readonly results: SpeechRecognitionResultList; @@ -54,56 +33,64 @@ declare global { new (): SpeechRecognition; prototype: SpeechRecognition; }; - webkitAudioContext?: typeof AudioContext; } } export function useSpeechSynthesis() { const [speechState, setSpeechState] = useState<SpeechState>("idle"); const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null); - const audioContextRef = useRef<AudioContext | null>(null); - const streamAbortControllerRef = useRef<AbortController | null>(null); - const activeSourceNodesRef = useRef<Set<AudioBufferSourceNode>>(new Set()); - const streamIdRef = useRef<string | null>(null); - const closeUrlRef = useRef<string | null>(null); - const statusUrlRef = useRef<string | null>(null); - const resultUrlRef = useRef<string | null>(null); - const statusPollTimeoutRef = useRef<number | null>(null); + const audioRef = useRef<HTMLAudioElement | null>(null); + const currentAudioUrlRef = useRef<string | null>(null); + const audioObjectUrlsRef = useRef<Set<string>>(new Set()); + const fetchAbortControllersRef = useRef<Set<AbortController>>(new Set()); + const chunkAudioUrlCacheRef = useRef<Map<number, string>>(new Map()); + const chunkFetchPromisesRef = useRef<Map<number, Promise<string>>>(new Map()); + const chunksRef = useRef<string[]>([]); + const currentChunkIndexRef = useRef(0); + const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise<void>>( + async () => {}, + ); const playbackTokenRef = useRef(0); + const activeMessageIdRef = useRef<string | null>(null); const isSupported = typeof window !== "undefined" && - typeof window.FormData !== "undefined" && - (typeof window.AudioContext !== "undefined" || - typeof window.webkitAudioContext !== "undefined"); + typeof window.Audio !== "undefined" && + typeof window.URL !== "undefined" && + typeof window.fetch !== "undefined"; - const trimTrailingSlash = useCallback((value: string) => value.replace(/\/+$/, ""), []); + const detachCurrentAudio = useCallback((revokeCurrentUrl: boolean) => { + const audio = audioRef.current; + audioRef.current = null; + if (audio) { + audio.pause(); + audio.onended = null; + audio.onerror = null; + audio.removeAttribute("src"); + audio.load(); + } - const buildServiceUrl = useCallback( - (path: string) => `${trimTrailingSlash(config.AUDIO_SERVICE_URL)}${path.startsWith("/") ? path : `/${path}`}`, - [trimTrailingSlash], - ); + const currentUrl = currentAudioUrlRef.current; + currentAudioUrlRef.current = null; + if (revokeCurrentUrl && currentUrl) { + URL.revokeObjectURL(currentUrl); + audioObjectUrlsRef.current.delete(currentUrl); + chunkAudioUrlCacheRef.current.delete(currentChunkIndexRef.current); + } + }, []); - const resolveServiceUrl = useCallback( - (pathOrUrl: string) => { - if (/^https?:\/\//i.test(pathOrUrl)) { - return pathOrUrl; - } - return buildServiceUrl(pathOrUrl); - }, - [buildServiceUrl], - ); - - const withQueryParams = useCallback( - (urlString: string, params: Record<string, string>) => { - const url = new URL(urlString); - Object.entries(params).forEach(([key, value]) => { - url.searchParams.set(key, value); - }); - return url.toString(); - }, - [], - ); + const releaseAudio = useCallback(() => { + fetchAbortControllersRef.current.forEach((controller) => controller.abort()); + fetchAbortControllersRef.current.clear(); + chunkFetchPromisesRef.current.clear(); + detachCurrentAudio(false); + audioObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + audioObjectUrlsRef.current.clear(); + chunkAudioUrlCacheRef.current.clear(); + chunksRef.current = []; + currentChunkIndexRef.current = 0; + activeMessageIdRef.current = null; + }, [detachCurrentAudio]); const readErrorMessage = useCallback(async (response: Response, fallback: string) => { try { @@ -114,402 +101,170 @@ export function useSpeechSynthesis() { } }, []); - const closeStream = useCallback(async (closeUrl: string) => { - const response = await fetch(closeUrl, { - method: "POST", - }); + const fetchChunkAudio = useCallback( + (chunkIndex: number, playbackToken: number) => { + const cachedUrl = chunkAudioUrlCacheRef.current.get(chunkIndex); + if (cachedUrl) return Promise.resolve(cachedUrl); - if (!response.ok) { - console.error("[GlobalChatbox] Failed to close audio stream:", closeUrl); - } - }, []); + const existingPromise = chunkFetchPromisesRef.current.get(chunkIndex); + if (existingPromise) return existingPromise; - const stopStatusPolling = useCallback(() => { - if (statusPollTimeoutRef.current !== null) { - window.clearTimeout(statusPollTimeoutRef.current); - statusPollTimeoutRef.current = null; - } - }, []); - - const fetchStreamResult = useCallback( - async (resultUrl: string) => { - const response = await fetch(resultUrl); - if (response.status === 202) { - return false; - } - if (!response.ok) { - throw new Error( - await readErrorMessage( - response, - `Audio stream result failed with status ${response.status}`, - ), - ); + const chunkText = chunksRef.current[chunkIndex]; + if (!chunkText) { + return Promise.reject(new Error("Speech chunk is missing")); } - const payload = (await response.json()) as AudioStreamResultResponse; - if (payload.error) { - throw new Error(payload.error); - } + const abortController = new AbortController(); + fetchAbortControllersRef.current.add(abortController); - return true; + const promise = fetch("/api/tts/edge", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ text: chunkText }), + signal: abortController.signal, + }) + .then(async (response) => { + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Edge TTS failed with status ${response.status}`), + ); + } + const audioBlob = await response.blob(); + if (!audioBlob.size) { + throw new Error("Edge TTS returned empty audio"); + } + if (playbackToken !== playbackTokenRef.current) { + throw new DOMException("Edge TTS chunk cancelled", "AbortError"); + } + + const objectUrl = URL.createObjectURL(audioBlob); + audioObjectUrlsRef.current.add(objectUrl); + chunkAudioUrlCacheRef.current.set(chunkIndex, objectUrl); + return objectUrl; + }) + .finally(() => { + fetchAbortControllersRef.current.delete(abortController); + chunkFetchPromisesRef.current.delete(chunkIndex); + }); + + chunkFetchPromisesRef.current.set(chunkIndex, promise); + return promise; }, [readErrorMessage], ); - const clearAudio = useCallback(async () => { - const abortController = streamAbortControllerRef.current; - streamAbortControllerRef.current = null; - abortController?.abort(); - - activeSourceNodesRef.current.forEach((source) => { - try { - source.onended = null; - source.stop(); - } catch { - // ignore stop errors when source already ended - } - source.disconnect(); - }); - activeSourceNodesRef.current.clear(); - - const audioContext = audioContextRef.current; - audioContextRef.current = null; - if (!audioContext) return; - - try { - await audioContext.close(); - } catch { - // ignore close errors when context already closed - } - }, []); - - const playPcmStream = useCallback( - async ({ - audioUrl, - sampleRate, - channels, - playbackToken, - }: { - audioUrl: string; - sampleRate: number; - channels: number; - playbackToken: number; - }) => { - const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext; - if (!AudioContextCtor) { - throw new Error("WebAudio AudioContext is not available in this browser"); - } - - const abortController = new AbortController(); - streamAbortControllerRef.current = abortController; - - const response = await fetch(withQueryParams(audioUrl, { format: "pcm" }), { - signal: abortController.signal, + const prefetchChunk = useCallback( + (chunkIndex: number, playbackToken: number) => { + if (chunkIndex >= chunksRef.current.length) return; + void fetchChunkAudio(chunkIndex, playbackToken).catch((error) => { + if ( + playbackToken === playbackTokenRef.current && + !(error instanceof DOMException && error.name === "AbortError") + ) { + console.error("[GlobalChatbox] Failed to prefetch Edge TTS chunk:", error); + } }); - if (!response.ok) { - throw new Error( - await readErrorMessage(response, `Audio stream failed with status ${response.status}`), - ); - } - if (!response.body) { - throw new Error("Audio stream response body is missing"); - } - - const audioContext = new AudioContextCtor({ - sampleRate, - }); - audioContextRef.current = audioContext; - - const reader = response.body.getReader(); - const bytesPerFrame = Math.max(1, channels) * 2; - let bufferedRemainder = new Uint8Array(0); - let nextStartTime = audioContext.currentTime + 0.05; - let activeSources = 0; - let streamEnded = false; - let resolvePlaybackDrain: (() => void) | null = null; - const playbackDrainPromise = new Promise<void>((resolve) => { - resolvePlaybackDrain = resolve; - }); - - const maybeResolvePlaybackDrain = () => { - if (streamEnded && activeSources === 0) { - resolvePlaybackDrain?.(); - } - }; - - const schedulePcmChunk = (pcmBytes: Uint8Array) => { - const frameCount = pcmBytes.byteLength / bytesPerFrame; - if (frameCount <= 0) return; - - const buffer = audioContext.createBuffer(Math.max(1, channels), frameCount, sampleRate); - const view = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength); - for (let frame = 0; frame < frameCount; frame += 1) { - for (let channel = 0; channel < Math.max(1, channels); channel += 1) { - const sampleIndex = frame * Math.max(1, channels) + channel; - const pcm = view.getInt16(sampleIndex * 2, true); - buffer.getChannelData(channel)[frame] = pcm / 32768; - } - } - - const source = audioContext.createBufferSource(); - source.buffer = buffer; - source.connect(audioContext.destination); - const sourceStartTime = Math.max(nextStartTime, audioContext.currentTime + 0.01); - nextStartTime = sourceStartTime + buffer.duration; - - activeSources += 1; - activeSourceNodesRef.current.add(source); - source.onended = () => { - activeSources -= 1; - activeSourceNodesRef.current.delete(source); - source.disconnect(); - maybeResolvePlaybackDrain(); - }; - source.start(sourceStartTime); - }; - - const concatUint8Arrays = (a: Uint8Array, b: Uint8Array) => { - if (a.byteLength === 0) return b; - if (b.byteLength === 0) return a; - const merged = new Uint8Array(a.byteLength + b.byteLength); - merged.set(a); - merged.set(b, a.byteLength); - return merged; - }; - - while (true) { - if (playbackToken !== playbackTokenRef.current) { - throw new DOMException("PCM stream playback cancelled", "AbortError"); - } - - const { done, value } = await reader.read(); - if (done) break; - if (!value || value.byteLength === 0) continue; - - const merged = concatUint8Arrays(bufferedRemainder, value); - const alignedByteLength = merged.byteLength - (merged.byteLength % bytesPerFrame); - if (alignedByteLength === 0) { - bufferedRemainder = new Uint8Array(merged); - continue; - } - - const alignedChunk = merged.slice(0, alignedByteLength); - bufferedRemainder = new Uint8Array(merged.slice(alignedByteLength)); - schedulePcmChunk(alignedChunk); - } - - streamEnded = true; - maybeResolvePlaybackDrain(); - await playbackDrainPromise; }, - [readErrorMessage, withQueryParams], + [fetchChunkAudio], ); - const stopPlayback = useCallback(async () => { - await clearAudio(); - stopStatusPolling(); + const playChunk = useCallback( + async (chunkIndex: number, playbackToken: number) => { + setSpeechState("loading"); + const objectUrl = await fetchChunkAudio(chunkIndex, playbackToken); + if (playbackToken !== playbackTokenRef.current) return; - const closeUrl = closeUrlRef.current; - streamIdRef.current = null; - closeUrlRef.current = null; - statusUrlRef.current = null; - resultUrlRef.current = null; - setSpeechState("idle"); - setSpeakingMessageId(null); + detachCurrentAudio(true); + currentChunkIndexRef.current = chunkIndex; + const audio = new Audio(objectUrl); + audio.preload = "auto"; + audioRef.current = audio; + currentAudioUrlRef.current = objectUrl; - if (closeUrl) { - try { - await closeStream(closeUrl); - } catch (error) { - console.error("[GlobalChatbox] Failed to close audio stream:", error); - } - } - }, [clearAudio, closeStream, stopStatusPolling]); - - const pollStreamStatus = useCallback( - (playbackToken: number, statusUrl: string, resultUrl: string) => { - stopStatusPolling(); - - statusPollTimeoutRef.current = window.setTimeout(async () => { - if ( - playbackToken !== playbackTokenRef.current || - statusUrlRef.current !== statusUrl || - resultUrlRef.current !== resultUrl - ) { + audio.onended = () => { + if (playbackToken !== playbackTokenRef.current) return; + detachCurrentAudio(true); + const nextChunkIndex = chunkIndex + 1; + if (nextChunkIndex >= chunksRef.current.length) { + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); return; } - try { - const response = await fetch(statusUrl); - if (!response.ok) { - throw new Error( - await readErrorMessage( - response, - `Audio stream status failed with status ${response.status}`, - ), - ); - } + currentChunkIndexRef.current = nextChunkIndex; + void playChunkRef.current(nextChunkIndex, playbackToken); + }; + audio.onerror = () => { + if (playbackToken !== playbackTokenRef.current) return; + playbackTokenRef.current += 1; + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + console.error("[GlobalChatbox] Edge TTS audio playback failed"); + }; - const payload = (await response.json()) as AudioStreamStatusResponse; - if ( - playbackToken !== playbackTokenRef.current || - statusUrlRef.current !== statusUrl || - resultUrlRef.current !== resultUrl - ) { - return; - } - - if (payload.failed || payload.state === "failed") { - console.error( - "[GlobalChatbox] Audio stream failed:", - payload.error || payload.status_text || statusUrl, - ); - playbackTokenRef.current += 1; - void stopPlayback(); - return; - } - - if (payload.closed || payload.state === "closed") { - stopStatusPolling(); - return; - } - - if (payload.ready || payload.state === "done") { - try { - const isResultReady = await fetchStreamResult(resultUrl); - if (isResultReady) { - stopStatusPolling(); - return; - } - } catch (error) { - console.error("[GlobalChatbox] Failed to fetch audio stream result:", error); - } - } - - pollStreamStatus(playbackToken, statusUrl, resultUrl); - } catch (error) { - if ( - playbackToken === playbackTokenRef.current && - statusUrlRef.current === statusUrl && - resultUrlRef.current === resultUrl - ) { - console.error("[GlobalChatbox] Failed to poll audio stream status:", error); - pollStreamStatus(playbackToken, statusUrl, resultUrl); - } - } - }, 1000); + await audio.play(); + if (playbackToken !== playbackTokenRef.current) return; + setSpeechState("playing"); + prefetchChunk(chunkIndex + 1, playbackToken); }, - [fetchStreamResult, readErrorMessage, stopPlayback, stopStatusPolling], + [detachCurrentAudio, fetchChunkAudio, prefetchChunk, releaseAudio], ); - const stop = useCallback(() => { - playbackTokenRef.current += 1; - void stopPlayback(); - }, [stopPlayback]); + useEffect(() => { + playChunkRef.current = playChunk; + }, [playChunk]); + + const playExistingAudio = useCallback(async () => { + const audio = audioRef.current; + if (!audio) return false; + + setSpeakingMessageId(activeMessageIdRef.current); + setSpeechState("playing"); + try { + await audio.play(); + prefetchChunk(currentChunkIndexRef.current + 1, playbackTokenRef.current); + return true; + } catch (error) { + playbackTokenRef.current += 1; + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + console.error("[GlobalChatbox] Failed to resume Edge TTS playback:", error); + return false; + } + }, [prefetchChunk, releaseAudio]); const speak = useCallback( - async (messageId: string, text: string) => { + async (messageId: string, text: string, options: SpeakOptions = {}) => { const normalizedText = text.trim(); if (!isSupported || !normalizedText) return; + const startOffset = Math.max( + 0, + Math.min(options.startOffset ?? 0, normalizedText.length), + ); + const textToSpeak = normalizedText.slice(startOffset).trim(); + const chunks = splitSpeechTextIntoChunks(textToSpeak); + if (!chunks.length) return; + const playbackToken = playbackTokenRef.current + 1; playbackTokenRef.current = playbackToken; - await stopPlayback(); + releaseAudio(); + + chunksRef.current = chunks; + currentChunkIndexRef.current = 0; + activeMessageIdRef.current = messageId; setSpeakingMessageId(messageId); - setSpeechState("playing"); + setSpeechState("loading"); try { - const formData = new FormData(); - formData.append("text", normalizedText); - formData.append("demo_id", "demo-1"); - - const response = await fetch(buildServiceUrl("/api/generate-stream/start"), { - method: "POST", - body: formData, - }); - - if (!response.ok) { - throw new Error( - await readErrorMessage( - response, - `Audio stream start failed with status ${response.status}`, - ), - ); - } - - const payload = (await response.json()) as AudioStreamStartResponse; - const streamId = payload.stream_id; - const sampleRate = - typeof payload.sample_rate === "number" && payload.sample_rate > 0 - ? payload.sample_rate - : 24000; - const channels = - typeof payload.channels === "number" && payload.channels > 0 - ? payload.channels - : 1; - const audioUrl = payload.audio_url - ? resolveServiceUrl(payload.audio_url) - : buildServiceUrl( - `/api/generate-stream/${encodeURIComponent(streamId ?? "")}/audio?format=pcm`, - ); - const rawStatusUrl = payload.status_url - ? resolveServiceUrl(payload.status_url) - : buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/status`); - const statusUrl = withQueryParams(rawStatusUrl, { compact: "1" }); - const rawResultUrl = payload.result_url - ? resolveServiceUrl(payload.result_url) - : buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/result`); - const resultUrl = withQueryParams(rawResultUrl, { - compact: "1", - include_audio: "0", - }); - const closeUrl = buildServiceUrl( - `/api/generate-stream/${encodeURIComponent(streamId ?? "")}/close`, - ); - - if (!streamId) { - throw new Error(payload.error || "Audio stream start response is missing stream_id"); - } - - if (playbackToken !== playbackTokenRef.current) { - await closeStream(closeUrl); - return; - } - - streamIdRef.current = streamId; - closeUrlRef.current = closeUrl; - statusUrlRef.current = statusUrl; - resultUrlRef.current = resultUrl; - - pollStreamStatus(playbackToken, statusUrl, resultUrl); - await playPcmStream({ - audioUrl, - sampleRate, - channels, - playbackToken, - }); - - if (playbackToken !== playbackTokenRef.current) { - return; - } - - await clearAudio(); - if (streamIdRef.current === streamId) { - streamIdRef.current = null; - closeUrlRef.current = null; - statusUrlRef.current = null; - resultUrlRef.current = null; - setSpeechState("idle"); - setSpeakingMessageId(null); - } - stopStatusPolling(); - await fetchStreamResult(resultUrl).catch((error) => { - console.error("[GlobalChatbox] Failed to fetch audio stream result:", error); - }); - await closeStream(closeUrl); + await playChunk(0, playbackToken); } catch (error) { - await clearAudio(); if ( error instanceof DOMException && error.name === "AbortError" && @@ -517,73 +272,51 @@ export function useSpeechSynthesis() { ) { return; } - const closeUrl = closeUrlRef.current; - streamIdRef.current = null; - closeUrlRef.current = null; - statusUrlRef.current = null; - resultUrlRef.current = null; + + releaseAudio(); setSpeechState("idle"); setSpeakingMessageId(null); - if (closeUrl) { - try { - await closeStream(closeUrl); - } catch (closeError) { - console.error("[GlobalChatbox] Failed to close audio stream:", closeError); - } - } - console.error("[GlobalChatbox] Failed to play audio stream:", error); + console.error("[GlobalChatbox] Failed to play Edge TTS audio:", error); } }, - [ - buildServiceUrl, - clearAudio, - closeStream, - fetchStreamResult, - isSupported, - playPcmStream, - readErrorMessage, - resolveServiceUrl, - pollStreamStatus, - stopPlayback, - stopStatusPolling, - withQueryParams, - ], + [isSupported, playChunk, releaseAudio], ); const pause = useCallback(() => { - if (!isSupported || !audioContextRef.current) return; - void audioContextRef.current.suspend().then( - () => { - setSpeechState("paused"); - }, - (error) => { - console.error("[GlobalChatbox] Failed to pause PCM playback:", error); - }, - ); - }, [isSupported]); + const audio = audioRef.current; + if (!isSupported || !audio || speechState !== "playing") return; + audio.pause(); + setSpeechState("paused"); + }, [isSupported, speechState]); const resume = useCallback(() => { - if (!isSupported || !audioContextRef.current) return; - void audioContextRef.current.resume().then( - () => { - setSpeechState("playing"); - }, - (error) => { - playbackTokenRef.current += 1; - void stopPlayback(); - console.error("[GlobalChatbox] Failed to resume audio playback:", error); - }, - ); - }, [isSupported, stopPlayback]); + if (!isSupported) return; + void playExistingAudio(); + }, [isSupported, playExistingAudio]); + + const stop = useCallback(() => { + playbackTokenRef.current += 1; + releaseAudio(); + setSpeechState("idle"); + setSpeakingMessageId(null); + }, [releaseAudio]); useEffect(() => { return () => { playbackTokenRef.current += 1; - void stopPlayback(); + releaseAudio(); }; - }, [stopPlayback]); + }, [releaseAudio]); - return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported }; + return { + speechState, + speakingMessageId, + speak, + pause, + resume, + stop, + isSupported, + }; } export function useSpeechRecognition(onResult: (text: string) => void) { @@ -618,7 +351,6 @@ export function useSpeechRecognition(onResult: (text: string) => void) { recognition.onerror = () => { setIsListening(false); - recognitionRef.current = null; }; recognition.onend = () => { @@ -627,8 +359,8 @@ export function useSpeechRecognition(onResult: (text: string) => void) { }; recognitionRef.current = recognition; - recognition.start(); setIsListening(true); + recognition.start(); }, [isSupported]); const stop = useCallback(() => { @@ -639,7 +371,7 @@ export function useSpeechRecognition(onResult: (text: string) => void) { useEffect(() => { return () => { - recognitionRef.current?.stop(); + recognitionRef.current?.abort(); }; }, []); diff --git a/src/components/chat/speechStartOptions.test.ts b/src/components/chat/speechStartOptions.test.ts new file mode 100644 index 0000000..c92806d --- /dev/null +++ b/src/components/chat/speechStartOptions.test.ts @@ -0,0 +1,31 @@ +import { + findSpeechSelectionStartOffset, + splitSpeechTextIntoChunks, +} from "./speechStartOptions"; + +describe("findSpeechSelectionStartOffset", () => { + it("finds the reading start from selected reply text", () => { + const text = "第一段内容。\n\n第二段 包含空格。\n第三段内容。"; + + expect(findSpeechSelectionStartOffset(text, "第二段 包含空格")).toBe( + text.indexOf("第二段"), + ); + expect(findSpeechSelectionStartOffset(text, "第三段")).toBe(text.indexOf("第三段")); + expect(findSpeechSelectionStartOffset(text, "不存在")).toBeNull(); + }); +}); + +describe("splitSpeechTextIntoChunks", () => { + it("splits long text into bounded chunks", () => { + const text = Array.from({ length: 80 }, (_, index) => `第${index}句内容足够长。`).join(""); + const chunks = splitSpeechTextIntoChunks(text); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= 520)).toBe(true); + expect(chunks.join("")).toBe(text); + }); + + it("keeps short text as one chunk", () => { + expect(splitSpeechTextIntoChunks("短句。")).toEqual(["短句。"]); + }); +}); diff --git a/src/components/chat/speechStartOptions.ts b/src/components/chat/speechStartOptions.ts new file mode 100644 index 0000000..3c06966 --- /dev/null +++ b/src/components/chat/speechStartOptions.ts @@ -0,0 +1,98 @@ +const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim(); +const MAX_SPEECH_CHUNK_LENGTH = 520; +const MIN_SPEECH_CHUNK_LENGTH = 180; +const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g; + +const normalizeWithOffsetMap = (value: string) => { + let normalized = ""; + const offsetMap: number[] = []; + let isPreviousWhitespace = false; + + Array.from(value).forEach((char, index) => { + if (/\s/u.test(char)) { + if (!isPreviousWhitespace && normalized.length > 0) { + normalized += " "; + offsetMap.push(index); + } + isPreviousWhitespace = true; + return; + } + + normalized += char; + offsetMap.push(index); + isPreviousWhitespace = false; + }); + + return { + normalized: normalized.trimEnd(), + offsetMap, + }; +}; + +export function findSpeechSelectionStartOffset( + text: string, + selectedText: string, +): number | null { + const needle = selectedText.trim(); + if (!needle) return null; + + const exactIndex = text.indexOf(needle); + if (exactIndex >= 0) return exactIndex; + + const normalizedNeedle = compactWhitespace(needle); + if (!normalizedNeedle) return null; + + const haystack = normalizeWithOffsetMap(text); + const normalizedIndex = haystack.normalized.indexOf(normalizedNeedle); + if (normalizedIndex < 0) return null; + + return haystack.offsetMap[normalizedIndex] ?? null; +} + +export function splitSpeechTextIntoChunks(text: string): string[] { + const normalizedText = text.trim(); + if (!normalizedText) return []; + + const segments = Array.from(normalizedText.matchAll(SPEECH_SENTENCE_PATTERN), (match) => + compactWhitespace(match[0]), + ).filter(Boolean); + const sourceSegments = segments.length > 0 ? segments : [normalizedText]; + const chunks: string[] = []; + let currentChunk = ""; + + const flush = () => { + if (!currentChunk) return; + chunks.push(currentChunk); + currentChunk = ""; + }; + + const pushLongSegment = (segment: string) => { + for (let offset = 0; offset < segment.length; offset += MAX_SPEECH_CHUNK_LENGTH) { + chunks.push(segment.slice(offset, offset + MAX_SPEECH_CHUNK_LENGTH)); + } + }; + + sourceSegments.forEach((segment) => { + if (segment.length > MAX_SPEECH_CHUNK_LENGTH) { + flush(); + pushLongSegment(segment); + return; + } + + const candidate = currentChunk ? `${currentChunk}${segment}` : segment; + if ( + currentChunk && + candidate.length > MAX_SPEECH_CHUNK_LENGTH && + currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH + ) { + flush(); + currentChunk = segment; + return; + } + + currentChunk = candidate; + }); + + flush(); + return chunks; +} -- 2.54.0 From 758100b3450920280ce0d374c96d43a8a9fd25d4 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 18:54:57 +0800 Subject: [PATCH 204/281] fix(build): pin npm in Docker deps --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7da72fd..60c198d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,14 @@ FROM base AS deps RUN apk add --no-cache libc6-compat +ARG NPM_VERSION=10.9.8 +RUN npm install -g "npm@${NPM_VERSION}" + COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ - elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi -- 2.54.0 From 76e62a2d3d3442da4f0433c30f91c27f2e6ed6b2 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 18:58:19 +0800 Subject: [PATCH 205/281] fix(build): skip npm audit in Docker --- Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 60c198d..16adef8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,6 @@ FROM base AS deps RUN apk add --no-cache libc6-compat -ARG NPM_VERSION=10.9.8 -RUN npm install -g "npm@${NPM_VERSION}" - COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ RUN \ -- 2.54.0 From 67c1a88afea2cbb72c196d9fc98f5cb716f89738 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 19:20:29 +0800 Subject: [PATCH 206/281] fix(build): use Debian Node image --- Dockerfile | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 16adef8..158c2c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ -FROM refinedev/node:22 AS base +FROM node:22-bookworm-slim AS base + +WORKDIR /app/refine FROM base AS deps -RUN apk add --no-cache libc6-compat - COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ - elif [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ + elif [ -f package-lock.json ]; then npm ci; \ elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi @@ -40,12 +40,12 @@ ENV NODE_ENV=production COPY --from=builder /app/refine/public ./public RUN mkdir .next -RUN chown refine:nodejs .next +RUN chown node:node .next -COPY --from=builder --chown=refine:nodejs /app/refine/.next/standalone ./ -COPY --from=builder --chown=refine:nodejs /app/refine/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/refine/.next/standalone ./ +COPY --from=builder --chown=node:node /app/refine/.next/static ./.next/static -USER refine +USER node EXPOSE 3000 -- 2.54.0 From a4e7ab263a376dec6a34ea8856940f1317690700 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 19:22:44 +0800 Subject: [PATCH 207/281] fix(build): pin Node Docker tag --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 158c2c5..3fbc35b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-bookworm-slim AS base +FROM node:22.23.1-bookworm-slim AS base WORKDIR /app/refine -- 2.54.0 From 9c2a6a386ae1a44a31dba84902238ccd703f57f0 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 19:29:31 +0800 Subject: [PATCH 208/281] fix(build): use npm mirror in Docker --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 3fbc35b..c4f6080 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,9 @@ FROM node:22.23.1-bookworm-slim AS base WORKDIR /app/refine +ARG NPM_CONFIG_REGISTRY=https://registry.npmmirror.com +ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY} + FROM base AS deps COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ -- 2.54.0 From e2e296dc06f5270b3dd41dc51aee4174d8f6db0a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 19:31:56 +0800 Subject: [PATCH 209/281] fix(ci): pass npm mirror to Docker build --- .gitea/workflows/package.yml | 1 + Dockerfile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 6400def..27712a5 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -103,6 +103,7 @@ jobs: -f ./Dockerfile \ -t "${IMAGE_NAME}:${IMAGE_TAG}" \ -t "${IMAGE_NAME}:latest" \ + --build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \ --build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \ --build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \ --build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \ diff --git a/Dockerfile b/Dockerfile index c4f6080..1149b35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22.23.1-bookworm-slim AS base WORKDIR /app/refine -ARG NPM_CONFIG_REGISTRY=https://registry.npmmirror.com +ARG NPM_CONFIG_REGISTRY ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY} FROM base AS deps -- 2.54.0 From b13fbe7dca13a3ab355c883bef48de761f7db1d9 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 19:38:52 +0800 Subject: [PATCH 210/281] chore(ci): print npm install debug logs --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1149b35..1f8cefb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ - elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f package-lock.json ]; then npm ci || (echo "===== npm debug logs =====" && find /root/.npm/_logs -maxdepth 1 -type f -name "*-debug-0.log" -print -exec cat {} \; && exit 1); \ elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi -- 2.54.0 From 600a8703edf826ed09a7c124a73c64f1029a56c3 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 8 Jul 2026 19:41:46 +0800 Subject: [PATCH 211/281] fix(ci): use host network for Docker build --- .gitea/workflows/package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 27712a5..17c3ffb 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -100,6 +100,7 @@ jobs: } docker build \ + --network=host \ -f ./Dockerfile \ -t "${IMAGE_NAME}:${IMAGE_TAG}" \ -t "${IMAGE_NAME}:latest" \ -- 2.54.0 From 435a4172e4b78d2fd5c7c650d06a2b471240809d Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 9 Jul 2026 11:51:40 +0800 Subject: [PATCH 212/281] fix(burst-location): explain normal data source --- .../BurstLocation/AnalysisParameters.tsx | 13 ++++ .../olmap/BurstLocation/LocationResults.tsx | 62 +++++++++++++++++++ src/components/olmap/BurstLocation/types.ts | 4 ++ 3 files changed, 79 insertions(+) diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 7223617..2461912 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -239,6 +239,19 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <MenuItem value="simulation">模拟方案</MenuItem> </Select> </FormControl> + <Typography + variant="caption" + sx={{ + mt: 0.75, + display: "block", + color: "text.secondary", + lineHeight: 1.6, + }} + > + {isSimulationMode + ? "爆管数据读取所选方案模拟结果,正常数据读取实时模拟结果,两者使用同一爆管时间窗。" + : "爆管数据读取所选监测时间窗,正常数据默认读取前一天同一时段的监测数据。"} + </Typography> </Box> {isSimulationMode && ( diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index afef844..c654403 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -75,6 +75,33 @@ const toneStyles: Record< const formatDateTime = (value?: string) => value ? dayjs(value).format("MM-DD HH:mm") : "-"; +const formatDateTimeRange = (start?: string, end?: string) => { + if (!start || !end) return "-"; + return `${formatDateTime(start)} 至 ${formatDateTime(end)}`; +}; + +const getDataSourceLabel = (result: BurstLocationResult) => + result.data_source === "simulation" ? "模拟方案" : "监测数据"; + +const getNormalDataDescription = (result: BurstLocationResult) => { + switch (result.observed_source) { + case "simulation_scheme_burst_realtime_normal_timerange": + return "正常数据读取实时模拟结果,与爆管数据使用同一时间窗。"; + case "scada_burst_scada_normal_timerange": + return "正常数据读取监测数据;未单独指定正常时间窗时,默认使用爆管时段前一天同一时段。"; + case "scada_burst_payload_normal_timerange": + return "爆管数据读取监测数据,正常数据来自请求载荷。"; + case "simulation_scheme_timerange": + return "历史方案记录:爆管数据和正常数据均读取方案模拟结果。"; + case "scada_timerange": + return "历史方案记录:爆管数据和正常数据使用同一监测时间窗。"; + case "request_payload": + return "爆管数据和正常数据均来自请求载荷。"; + default: + return "正常数据按后端返回的数据源规则选择。"; + } +}; + const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { const style = toneStyles[tone]; return ( @@ -214,6 +241,16 @@ const LocationResults: React.FC<Props> = ({ result }) => { const burstTime = result.scada_window?.burst_start ? formatDateTime(result.scada_window.burst_start) : "-"; + const burstWindow = formatDateTimeRange( + result.scada_window?.burst_start, + result.scada_window?.burst_end, + ); + const normalWindow = formatDateTimeRange( + result.scada_window?.normal_start, + result.scada_window?.normal_end, + ); + const sourceLabel = getDataSourceLabel(result); + const normalDataDescription = getNormalDataDescription(result); return ( <Box className="h-full overflow-auto p-1"> @@ -287,6 +324,31 @@ const LocationResults: React.FC<Props> = ({ result }) => { tone="green" /> </Box> + + <Box className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"> + <Box className="mb-1 flex flex-wrap items-center gap-1.5"> + <Chip + size="small" + label={sourceLabel} + color={result.data_source === "simulation" ? "secondary" : "primary"} + sx={{ height: 22, fontSize: "0.72rem", fontWeight: 600 }} + /> + <Typography variant="caption" className="text-gray-600"> + 正常数据选择说明 + </Typography> + </Box> + <Typography variant="caption" className="block leading-5 text-gray-700"> + {normalDataDescription} + </Typography> + <Typography variant="caption" className="mt-1 block leading-5 text-gray-500"> + 爆管窗口: {burstWindow};正常窗口: {normalWindow} + </Typography> + {result.data_source === "simulation" && result.simulation_scheme?.name ? ( + <Typography variant="caption" className="mt-1 block truncate text-purple-600"> + 爆管方案: {result.simulation_scheme.name} + </Typography> + ) : null} + </Box> </Box> {/* Candidate List */} diff --git a/src/components/olmap/BurstLocation/types.ts b/src/components/olmap/BurstLocation/types.ts index 8c2ffae..0500a36 100644 --- a/src/components/olmap/BurstLocation/types.ts +++ b/src/components/olmap/BurstLocation/types.ts @@ -23,6 +23,8 @@ export interface BurstLocationResult { scada_window?: { burst_start?: string; burst_end?: string; + normal_start?: string; + normal_end?: string; }; pressure_samples?: { burst?: number; @@ -51,6 +53,8 @@ export interface BurstLocationSchemeDetail { scada_window?: { burst_start?: string; burst_end?: string; + normal_start?: string; + normal_end?: string; }; result_summary?: { located_pipe?: string; -- 2.54.0 From 701c5a949dfb3ee2874d5b9549162d0bed943ad6 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 9 Jul 2026 11:55:49 +0800 Subject: [PATCH 213/281] fix(burst-location): surface data source note --- .../BurstLocation/AnalysisParameters.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 2461912..5d571f0 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useState } from "react"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import RefreshIcon from "@mui/icons-material/Refresh"; import { + Alert, Box, Button, CircularProgress, @@ -239,19 +240,26 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <MenuItem value="simulation">模拟方案</MenuItem> </Select> </FormControl> - <Typography - variant="caption" + <Alert + severity="info" + variant="outlined" sx={{ - mt: 0.75, - display: "block", - color: "text.secondary", - lineHeight: 1.6, + mt: 1, + alignItems: "flex-start", + borderColor: "info.light", + backgroundColor: "#eff6ff", + "& .MuiAlert-message": { width: "100%" }, }} > - {isSimulationMode - ? "爆管数据读取所选方案模拟结果,正常数据读取实时模拟结果,两者使用同一爆管时间窗。" - : "爆管数据读取所选监测时间窗,正常数据默认读取前一天同一时段的监测数据。"} - </Typography> + <Typography variant="caption" className="block font-semibold text-blue-900"> + 数据选择规则 + </Typography> + <Typography variant="caption" className="block leading-5 text-blue-800"> + {isSimulationMode + ? "当前为模拟方案:爆管数据取所选方案模拟结果,正常数据取实时模拟结果,两者使用同一爆管时间窗。" + : "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"} + </Typography> + </Alert> </Box> {isSimulationMode && ( -- 2.54.0 From 694f7629eee68d886d2b4373a4aea63e22ae1602 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 9 Jul 2026 13:59:01 +0800 Subject: [PATCH 214/281] fix(analysis): preserve tab panel state --- .../BurstDetection/AnalysisParameters.tsx | 123 ++++++++++++------ .../BurstDetection/BurstDetectionPanel.tsx | 47 ++++++- .../olmap/BurstDetection/DetectionResults.tsx | 29 ++++- .../olmap/BurstDetection/SchemeQuery.tsx | 42 +++++- .../BurstLocation/AnalysisParameters.tsx | 110 +++++++++++----- .../BurstLocation/BurstLocationPanel.tsx | 32 ++++- .../olmap/BurstLocation/SchemeQuery.tsx | 42 +++++- .../BurstSimulation/AnalysisParameters.tsx | 80 +++++++++--- .../BurstPipeAnalysisPanel.tsx | 31 ++++- .../olmap/BurstSimulation/SchemeQuery.tsx | 74 ++++++++--- .../olmap/BurstSimulation/ValveIsolation.tsx | 69 +++++++++- .../AnalysisParameters.tsx | 87 ++++++++++--- .../ContaminantSimulation/SchemeQuery.tsx | 62 +++++++-- .../WaterQualityPanel.tsx | 25 +++- .../DMALeakDetection/AnalysisParameters.tsx | 78 ++++++++--- .../DMALeakDetectionPanel.tsx | 31 ++++- .../olmap/DMALeakDetection/SchemeQuery.tsx | 43 +++++- .../FlushingAnalysis/AnalysisParameters.tsx | 122 +++++++++++++---- .../FlushingAnalysisPanel.tsx | 29 ++++- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 73 ++++++++--- .../MonitoringPlaceOptimizationPanel.tsx | 22 +++- .../OptimizationParameters.tsx | 58 +++++++-- .../SchemeQuery.tsx | 36 ++++- .../olmap/core/useControllableState.ts | 55 ++++++++ 24 files changed, 1119 insertions(+), 281 deletions(-) create mode 100644 src/components/olmap/core/useControllableState.ts diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index 2b0efba..864b3e0 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -24,13 +24,16 @@ import dayjs, { Dayjs } from "dayjs"; import "dayjs/locale/zh-cn"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { BurstDetectionResult } from "./types"; interface Props { onResult: (result: BurstDetectionResult) => void; + state?: BurstDetectionAnalysisParametersState; + onStateChange?: (state: BurstDetectionAnalysisParametersState) => void; } -interface SchemeItem { +export interface SchemeItem { scheme_id: number; scheme_name: string; scheme_type: string; @@ -41,20 +44,60 @@ interface SchemeItem { }; } -const AnalysisParameters: React.FC<Props> = ({ onResult }) => { +export interface BurstDetectionAnalysisParametersState { + schemeName: string; + dataSource: "monitoring" | "simulation"; + schemes: SchemeItem[]; + selectedSchemeId: number | ""; + scadaStart: Dayjs | null; + scadaEnd: Dayjs | null; + mu: number; + pointsPerDay: number; + nEstimators: number; + contaminationInput: string; + advancedOpen: boolean; +} + +export const createBurstDetectionAnalysisParametersState = + (): BurstDetectionAnalysisParametersState => ({ + schemeName: `Burst_Detection_${Date.now()}`, + dataSource: "monitoring", + schemes: [], + selectedSchemeId: "", + scadaStart: dayjs().subtract(3, "day"), + scadaEnd: dayjs(), + mu: 100, + pointsPerDay: 96, + nEstimators: 50, + contaminationInput: "auto", + advancedOpen: false, + }); + +const AnalysisParameters: React.FC<Props> = ({ + onResult, + state, + onStateChange, +}) => { const { open } = useNotification(); - const [schemeName, setSchemeName] = useState(`Burst_Detection_${Date.now()}`); - const [dataSource, setDataSource] = useState<"monitoring" | "simulation">("monitoring"); - const [schemes, setSchemes] = useState<SchemeItem[]>([]); - const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">(""); + const [parametersState, setParametersState, setFormField] = useControllableObjectState( + state, + onStateChange, + createBurstDetectionAnalysisParametersState(), + ); + const { + schemeName, + dataSource, + schemes, + selectedSchemeId, + scadaStart, + scadaEnd, + mu, + pointsPerDay, + nEstimators, + contaminationInput, + advancedOpen, + } = parametersState; const [schemeLoading, setSchemeLoading] = useState(false); - const [scadaStart, setScadaStart] = useState<Dayjs | null>(dayjs().subtract(3, "day")); - const [scadaEnd, setScadaEnd] = useState<Dayjs | null>(dayjs()); - const [mu, setMu] = useState<number>(100); - const [pointsPerDay, setPointsPerDay] = useState<number>(96); - const [nEstimators, setNEstimators] = useState<number>(50); - const [contaminationInput, setContaminationInput] = useState<string>("auto"); - const [advancedOpen, setAdvancedOpen] = useState(false); const [running, setRunning] = useState(false); const isSimulationMode = dataSource === "simulation"; @@ -63,9 +106,12 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; const end = start.add(durationSeconds, "second"); - setScadaStart(start); - setScadaEnd(end); - }, []); + setParametersState((previous) => ({ + ...previous, + scadaStart: start, + scadaEnd: end, + })); + }, [setParametersState]); const fetchSchemes = useCallback( async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { @@ -80,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { (scheme) => scheme.scheme_type === "burst_analysis", ); - setSchemes(burstSchemes); + setFormField("schemes", burstSchemes); if (selectedSchemeId) { const matchedScheme = burstSchemes.find( @@ -89,7 +135,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { if (matchedScheme) { applySchemeTimeRange(matchedScheme); } else { - setSelectedSchemeId(""); + setFormField("selectedSchemeId", ""); } } @@ -111,18 +157,18 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setSchemeLoading(false); } }, - [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId], + [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField], ); const handleDataSourceChange = (value: "monitoring" | "simulation") => { - setDataSource(value); + setFormField("dataSource", value); if (value === "simulation") { void fetchSchemes(); } }; const handleSchemeSelect = (schemeId: number) => { - setSelectedSchemeId(schemeId); + setFormField("selectedSchemeId", schemeId); const scheme = schemes.find((item) => item.scheme_id === schemeId); if (scheme) { applySchemeTimeRange(scheme); @@ -239,7 +285,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <TextField value={schemeName} - onChange={(event) => setSchemeName(event.target.value)} + onChange={(event) => setFormField("schemeName", event.target.value)} placeholder="请输入方案名称" fullWidth size="small" @@ -318,7 +364,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <DateTimePicker value={scadaStart} - onChange={setScadaStart} + onChange={(value) => setFormField("scadaStart", value)} maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined} disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" @@ -331,7 +377,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <DateTimePicker value={scadaEnd} - onChange={setScadaEnd} + onChange={(value) => setFormField("scadaEnd", value)} minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined} disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" @@ -356,10 +402,10 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <Box role="button" tabIndex={0} - onClick={() => setAdvancedOpen((prev) => !prev)} + onClick={() => setFormField("advancedOpen", !advancedOpen)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { - setAdvancedOpen((prev) => !prev); + setFormField("advancedOpen", !advancedOpen); } }} sx={{ @@ -397,7 +443,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { type="number" label="频域截断系数" value={mu} - onChange={(event) => setMu(Number(event.target.value))} + onChange={(event) => setFormField("mu", Number(event.target.value))} size="small" fullWidth inputProps={{ min: 1 }} @@ -406,7 +452,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { type="number" label="每日采样点数" value={pointsPerDay} - onChange={(event) => setPointsPerDay(Number(event.target.value))} + onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))} size="small" fullWidth inputProps={{ min: 1 }} @@ -415,7 +461,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { type="number" label="孤立森林树数量" value={nEstimators} - onChange={(event) => setNEstimators(Number(event.target.value))} + onChange={(event) => setFormField("nEstimators", Number(event.target.value))} size="small" fullWidth inputProps={{ min: 1 }} @@ -423,7 +469,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <TextField label="异常比例" value={contaminationInput} - onChange={(event) => setContaminationInput(event.target.value)} + onChange={(event) => setFormField("contaminationInput", event.target.value)} size="small" fullWidth helperText="填写 auto 或 0~0.5 之间的小数。" @@ -442,13 +488,16 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { disabled={running} sx={{ textTransform: "none", fontWeight: 500 }} onClick={() => { - setSchemeName(`Burst_Detection_${Date.now()}`); - setScadaStart(dayjs().subtract(3, "day")); - setScadaEnd(dayjs()); - setMu(100); - setPointsPerDay(96); - setNEstimators(50); - setContaminationInput("auto"); + setParametersState((previous) => ({ + ...previous, + schemeName: `Burst_Detection_${Date.now()}`, + scadaStart: dayjs().subtract(3, "day"), + scadaEnd: dayjs(), + mu: 100, + pointsPerDay: 96, + nEstimators: 50, + contaminationInput: "auto", + })); }} > 重置 diff --git a/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx index 54a6f21..d4ab172 100644 --- a/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx +++ b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx @@ -9,9 +9,18 @@ import { FormatListBulleted, Search as SearchIcon, } from "@mui/icons-material"; -import AnalysisParameters from "./AnalysisParameters"; -import DetectionResults from "./DetectionResults"; -import SchemeQuery from "./SchemeQuery"; +import AnalysisParameters, { + createBurstDetectionAnalysisParametersState, + type BurstDetectionAnalysisParametersState, +} from "./AnalysisParameters"; +import DetectionResults, { + createBurstDetectionResultsState, + type BurstDetectionResultsState, +} from "./DetectionResults"; +import SchemeQuery, { + createBurstDetectionSchemeQueryState, + type BurstDetectionSchemeQueryState, +} from "./SchemeQuery"; import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types"; const TabPanel = ({ @@ -33,6 +42,18 @@ const BurstDetectionPanel: React.FC = () => { const [tab, setTab] = useState(0); const [result, setResult] = useState<BurstDetectionResult | null>(null); const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]); + const [analysisState, setAnalysisState] = + useState<BurstDetectionAnalysisParametersState>( + createBurstDetectionAnalysisParametersState, + ); + const [queryState, setQueryState] = + useState<BurstDetectionSchemeQueryState>( + createBurstDetectionSchemeQueryState, + ); + const [resultsState, setResultsState] = + useState<BurstDetectionResultsState>( + createBurstDetectionResultsState, + ); const drawerWidth = 450; const panelTitle = "爆管侦测"; @@ -137,13 +158,27 @@ const BurstDetectionPanel: React.FC = () => { </Box> <TabPanel value={tab} index={0}> - <AnalysisParameters onResult={handleResult} /> + <AnalysisParameters + onResult={handleResult} + state={analysisState} + onStateChange={setAnalysisState} + /> </TabPanel> <TabPanel value={tab} index={1}> - <SchemeQuery onViewResult={handleResult} schemes={schemes} onSchemesChange={setSchemes} /> + <SchemeQuery + onViewResult={handleResult} + schemes={schemes} + onSchemesChange={setSchemes} + state={queryState} + onStateChange={setQueryState} + /> </TabPanel> <TabPanel value={tab} index={2}> - <DetectionResults result={result} /> + <DetectionResults + result={result} + state={resultsState} + onStateChange={setResultsState} + /> </TabPanel> </Box> </Drawer> diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx index d216eee..8102aaa 100644 --- a/src/components/olmap/BurstDetection/DetectionResults.tsx +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -24,8 +24,19 @@ import { Circle, Fill, Stroke, Style } from "ol/style"; import { bbox, featureCollection } from "@turf/turf"; import { BurstDetectionResult, BurstDetectionRow } from "./types"; +export interface BurstDetectionResultsState { + selectedDay: number | null; +} + +export const createBurstDetectionResultsState = + (): BurstDetectionResultsState => ({ + selectedDay: null, + }); + interface Props { result: BurstDetectionResult | null; + state?: BurstDetectionResultsState; + onStateChange?: (state: BurstDetectionResultsState) => void; } interface MetricCardProps { @@ -106,11 +117,25 @@ const getScoreLevel = (score: number) => { const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"); -const DetectionResults: React.FC<Props> = ({ result }) => { +const DetectionResults: React.FC<Props> = ({ + result, + state, + onStateChange, +}) => { const map = useMap(); const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); - const [selectedDay, setSelectedDay] = useState<number | null>(null); + const [internalResultsState, setInternalResultsState] = + useState<BurstDetectionResultsState>(createBurstDetectionResultsState); + const resultsState = state ?? internalResultsState; + const selectedDay = resultsState.selectedDay; + const setSelectedDay = (value: number | null) => { + const nextState = { ...resultsState, selectedDay: value }; + if (state === undefined) { + setInternalResultsState(nextState); + } + onStateChange?.(nextState); + }; useEffect(() => { if (!map) return; diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx index 3d292e2..c773b0e 100644 --- a/src/components/olmap/BurstDetection/SchemeQuery.tsx +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -23,6 +23,7 @@ import "dayjs/locale/zh-cn"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME } from "@config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { BurstDetectionResult, BurstDetectionSchemeDetail, @@ -33,15 +34,39 @@ interface Props { onViewResult: (result: BurstDetectionResult) => void; schemes?: BurstDetectionSchemeRecord[]; onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void; + state?: BurstDetectionSchemeQueryState; + onStateChange?: (state: BurstDetectionSchemeQueryState) => void; } -const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => { +export interface BurstDetectionSchemeQueryState { + queryAll: boolean; + queryDate: Dayjs | null; + expandedId: number | null; +} + +export const createBurstDetectionSchemeQueryState = + (): BurstDetectionSchemeQueryState => ({ + queryAll: true, + queryDate: dayjs(), + expandedId: null, + }); + +const SchemeQuery: React.FC<Props> = ({ + onViewResult, + schemes: externalSchemes, + onSchemesChange, + state, + onStateChange, +}) => { const { open } = useNotification(); - const [queryAll, setQueryAll] = useState(true); - const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); + const [queryState, , setQueryField] = useControllableObjectState( + state, + onStateChange, + createBurstDetectionSchemeQueryState(), + ); + const { queryAll, queryDate, expandedId } = queryState; const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]); const [loading, setLoading] = useState(false); - const [expandedId, setExpandedId] = useState<number | null>(null); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; @@ -159,7 +184,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <Checkbox size="small" checked={queryAll} - onChange={(event) => setQueryAll(event.target.checked)} + onChange={(event) => setQueryField("queryAll", event.target.checked)} /> } label={<Typography variant="body2">查询全部</Typography>} @@ -168,7 +193,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <DatePicker value={queryDate} - onChange={setQueryDate} + onChange={(value) => setQueryField("queryDate", value)} disabled={queryAll} format="YYYY-MM-DD" slotProps={{ textField: { size: "small", sx: { width: 180 } } }} @@ -241,7 +266,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <IconButton size="small" onClick={() => - setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id) + setQueryField( + "expandedId", + expandedId === scheme.scheme_id ? null : scheme.scheme_id, + ) } color="primary" className="p-1" diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 5d571f0..4a872c2 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -25,14 +25,17 @@ import dayjs, { Dayjs } from "dayjs"; import "dayjs/locale/zh-cn"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { BurstLocationResult } from "./types"; interface Props { onResult: (result: BurstLocationResult) => void; + state?: BurstLocationAnalysisParametersState; + onStateChange?: (state: BurstLocationAnalysisParametersState) => void; } -interface SchemeItem { +export interface SchemeItem { scheme_id: number; scheme_name: string; scheme_type: string; @@ -45,24 +48,60 @@ interface SchemeItem { type DataSource = "monitoring" | "simulation"; -const AnalysisParameters: React.FC<Props> = ({ onResult }) => { +export interface BurstLocationAnalysisParametersState { + schemeName: string; + dataSource: DataSource; + schemes: SchemeItem[]; + selectedSchemeId: number | ""; + burstLeakage: number; + enableFlow: boolean; + burstStartTime: Dayjs | null; + burstEndTime: Dayjs | null; + minDpressure: number; + basicPressure: number; + advancedOpen: boolean; +} + +export const createBurstLocationAnalysisParametersState = + (): BurstLocationAnalysisParametersState => ({ + schemeName: `Burst_Locate_${Date.now()}`, + dataSource: "monitoring", + schemes: [], + selectedSchemeId: "", + burstLeakage: 1440, + enableFlow: false, + burstStartTime: dayjs().subtract(20, "minute"), + burstEndTime: dayjs().subtract(5, "minute"), + minDpressure: 2, + basicPressure: 10, + advancedOpen: false, + }); + +const AnalysisParameters: React.FC<Props> = ({ + onResult, + state, + onStateChange, +}) => { const { open } = useNotification(); - const [schemeName, setSchemeName] = useState(`Burst_Locate_${Date.now()}`); - const [dataSource, setDataSource] = useState<DataSource>("monitoring"); - const [schemes, setSchemes] = useState<SchemeItem[]>([]); - const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">(""); + const [parametersState, setParametersState, setFormField] = useControllableObjectState( + state, + onStateChange, + createBurstLocationAnalysisParametersState(), + ); + const { + schemeName, + dataSource, + schemes, + selectedSchemeId, + burstLeakage, + enableFlow, + burstStartTime, + burstEndTime, + minDpressure, + basicPressure, + advancedOpen, + } = parametersState; const [schemeLoading, setSchemeLoading] = useState(false); - const [burstLeakage, setBurstLeakage] = useState<number>(1440); - const [enableFlow, setEnableFlow] = useState(false); - const [burstStartTime, setBurstStartTime] = useState<Dayjs | null>( - dayjs().subtract(20, "minute"), - ); - const [burstEndTime, setBurstEndTime] = useState<Dayjs | null>( - dayjs().subtract(5, "minute"), - ); - const [minDpressure, setMinDpressure] = useState<number>(2); - const [basicPressure, setBasicPressure] = useState<number>(10); - const [advancedOpen, setAdvancedOpen] = useState(false); const [running, setRunning] = useState(false); const isSimulationMode = dataSource === "simulation"; @@ -71,9 +110,12 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; const end = start.add(durationSeconds, "second"); - setBurstStartTime(start); - setBurstEndTime(end); - }, []); + setParametersState((previous) => ({ + ...previous, + burstStartTime: start, + burstEndTime: end, + })); + }, [setParametersState]); const fetchSchemes = useCallback( async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { @@ -88,7 +130,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { (scheme) => scheme.scheme_type === "burst_analysis", ); - setSchemes(burstSchemes); + setFormField("schemes", burstSchemes); if (selectedSchemeId) { const matchedScheme = burstSchemes.find( @@ -97,7 +139,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { if (matchedScheme) { applySchemeTimeRange(matchedScheme); } else { - setSelectedSchemeId(""); + setFormField("selectedSchemeId", ""); } } @@ -119,18 +161,18 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { setSchemeLoading(false); } }, - [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId], + [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField], ); const handleDataSourceChange = (value: DataSource) => { - setDataSource(value); + setFormField("dataSource", value); if (value === "simulation") { void fetchSchemes(); } }; const handleSchemeSelect = (schemeId: number) => { - setSelectedSchemeId(schemeId); + setFormField("selectedSchemeId", schemeId); const scheme = schemes.find((item) => item.scheme_id === schemeId); if (scheme) { applySchemeTimeRange(scheme); @@ -220,7 +262,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <TextField value={schemeName} - onChange={(e) => setSchemeName(e.target.value)} + onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" fullWidth size="small" @@ -321,7 +363,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <DateTimePicker value={burstStartTime} - onChange={setBurstStartTime} + onChange={(value) => setFormField("burstStartTime", value)} maxDateTime={burstEndTime ?? undefined} disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" @@ -334,7 +376,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <DateTimePicker value={burstEndTime} - onChange={setBurstEndTime} + onChange={(value) => setFormField("burstEndTime", value)} minDateTime={burstStartTime ?? undefined} disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" @@ -354,7 +396,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={burstLeakage} onChange={(e) => { const value = Number(e.target.value); - setBurstLeakage(Number.isNaN(value) ? 1440 : Math.max(0, value)); + setFormField("burstLeakage", Number.isNaN(value) ? 1440 : Math.max(0, value)); }} fullWidth inputProps={{ min: 0, step: 10 }} @@ -370,9 +412,9 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <Box role="button" tabIndex={0} - onClick={() => setAdvancedOpen((prev) => !prev)} + onClick={() => setFormField("advancedOpen", !advancedOpen)} onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") setAdvancedOpen((prev) => !prev); + if (e.key === "Enter" || e.key === " ") setFormField("advancedOpen", !advancedOpen); }} sx={{ display: "flex", @@ -412,7 +454,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <FormControl fullWidth size="small"> <Select value={enableFlow ? "enabled" : "disabled"} - onChange={(e) => setEnableFlow(e.target.value === "enabled")} + onChange={(e) => setFormField("enableFlow", e.target.value === "enabled")} > <MenuItem value="disabled">禁用</MenuItem> <MenuItem value="enabled">启用(使用流量计)</MenuItem> @@ -425,14 +467,14 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { label="最小压降 (m)" size="small" value={minDpressure} - onChange={(e) => setMinDpressure(Number(e.target.value))} + onChange={(e) => setFormField("minDpressure", Number(e.target.value))} /> <TextField type="number" label="基础压力 (m)" size="small" value={basicPressure} - onChange={(e) => setBasicPressure(Number(e.target.value))} + onChange={(e) => setFormField("basicPressure", Number(e.target.value))} /> </Box> </Box> diff --git a/src/components/olmap/BurstLocation/BurstLocationPanel.tsx b/src/components/olmap/BurstLocation/BurstLocationPanel.tsx index 9643d0f..f97b87c 100644 --- a/src/components/olmap/BurstLocation/BurstLocationPanel.tsx +++ b/src/components/olmap/BurstLocation/BurstLocationPanel.tsx @@ -9,9 +9,15 @@ import { FormatListBulleted, Search as SearchIcon, } from "@mui/icons-material"; -import AnalysisParameters from "./AnalysisParameters"; +import AnalysisParameters, { + createBurstLocationAnalysisParametersState, + type BurstLocationAnalysisParametersState, +} from "./AnalysisParameters"; import LocationResults from "./LocationResults"; -import SchemeQuery from "./SchemeQuery"; +import SchemeQuery, { + createBurstLocationSchemeQueryState, + type BurstLocationSchemeQueryState, +} from "./SchemeQuery"; import { BurstLocationResult, BurstSchemeRecord } from "./types"; const TabPanel = ({ @@ -33,6 +39,14 @@ const BurstLocationPanel: React.FC = () => { const [tab, setTab] = useState(0); const [result, setResult] = useState<BurstLocationResult | null>(null); const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]); + const [analysisState, setAnalysisState] = + useState<BurstLocationAnalysisParametersState>( + createBurstLocationAnalysisParametersState, + ); + const [queryState, setQueryState] = + useState<BurstLocationSchemeQueryState>( + createBurstLocationSchemeQueryState, + ); const drawerWidth = 450; const panelTitle = "爆管定位"; @@ -146,10 +160,20 @@ const BurstLocationPanel: React.FC = () => { </Box> <TabPanel value={tab} index={0}> - <AnalysisParameters onResult={handleResult} /> + <AnalysisParameters + onResult={handleResult} + state={analysisState} + onStateChange={setAnalysisState} + /> </TabPanel> <TabPanel value={tab} index={1}> - <SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} /> + <SchemeQuery + onViewResult={handleViewResult} + schemes={schemes} + onSchemesChange={setSchemes} + state={queryState} + onStateChange={setQueryState} + /> </TabPanel> <TabPanel value={tab} index={2}> <LocationResults result={result} /> diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index bd3a100..d83ccca 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -23,6 +23,7 @@ import dayjs, { Dayjs } from "dayjs"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { BurstLocationResult, BurstLocationSchemeDetail, @@ -34,15 +35,39 @@ interface Props { onViewResult: (result: BurstLocationResult) => void; schemes?: BurstSchemeRecord[]; onSchemesChange?: (schemes: BurstSchemeRecord[]) => void; + state?: BurstLocationSchemeQueryState; + onStateChange?: (state: BurstLocationSchemeQueryState) => void; } -const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => { +export interface BurstLocationSchemeQueryState { + queryAll: boolean; + queryDate: Dayjs | null; + expandedId: number | null; +} + +export const createBurstLocationSchemeQueryState = + (): BurstLocationSchemeQueryState => ({ + queryAll: true, + queryDate: dayjs(), + expandedId: null, + }); + +const SchemeQuery: React.FC<Props> = ({ + onViewResult, + schemes: externalSchemes, + onSchemesChange, + state, + onStateChange, +}) => { const { open } = useNotification(); - const [queryAll, setQueryAll] = useState(true); - const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); + const [queryState, , setQueryField] = useControllableObjectState( + state, + onStateChange, + createBurstLocationSchemeQueryState(), + ); + const { queryAll, queryDate, expandedId } = queryState; const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [loading, setLoading] = useState(false); - const [expandedId, setExpandedId] = useState<number | null>(null); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; @@ -157,7 +182,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <Checkbox size="small" checked={queryAll} - onChange={(e) => setQueryAll(e.target.checked)} + onChange={(e) => setQueryField("queryAll", e.target.checked)} /> } label={<Typography variant="body2">查询全部</Typography>} @@ -166,7 +191,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <DatePicker value={queryDate} - onChange={setQueryDate} + onChange={(value) => setQueryField("queryDate", value)} disabled={queryAll} format="YYYY-MM-DD" slotProps={{ textField: { size: "small", sx: { width: 200 } } }} @@ -281,7 +306,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <IconButton size="small" onClick={() => - setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id) + setQueryField( + "expandedId", + expandedId === scheme.scheme_id ? null : scheme.scheme_id, + ) } color="primary" className="p-1" diff --git a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx index c2b6fe5..419d66e 100644 --- a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx @@ -20,33 +20,60 @@ import { useMap } from "@components/olmap/core/MapComponent"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Style, Stroke, Icon } from "ol/style"; -import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; +import { + handleMapClickSelectFeatures as mapClickSelectFeatures, + queryFeaturesByIds, +} from "@/utils/mapQueryService"; import Feature, { FeatureLike } from "ol/Feature"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { along, lineString, length, toMercator } from "@turf/turf"; import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; -interface PipePoint { +export interface PipePoint { id: string; diameter: number; area: number; - feature?: any; // 存储管道要素用于高亮 } -const AnalysisParameters: React.FC = () => { +export interface BurstAnalysisParametersState { + pipePoints: PipePoint[]; + startTime: Dayjs | null; + duration: number; + schemeName: string; + network: string; +} + +export const createBurstAnalysisParametersState = (): BurstAnalysisParametersState => ({ + pipePoints: [], + startTime: dayjs(new Date()), + duration: 3600, + schemeName: "FANGAN" + new Date().getTime(), + network: NETWORK_NAME, +}); + +interface AnalysisParametersProps { + state?: BurstAnalysisParametersState; + onStateChange?: (state: BurstAnalysisParametersState) => void; +} + +const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ + state, + onStateChange, +}) => { const map = useMap(); const { open } = useNotification(); - const [pipePoints, setPipePoints] = useState<PipePoint[]>([]); - const [startTime, setStartTime] = useState<Dayjs | null>(dayjs(new Date())); - const [duration, setDuration] = useState<number>(3600); - const [schemeName, setSchemeName] = useState<string>( - "FANGAN" + new Date().getTime(), + const [parametersState, setParametersState, setParameterField] = useControllableObjectState( + state, + onStateChange, + createBurstAnalysisParametersState(), ); - const [network, setNetwork] = useState<string>(NETWORK_NAME); + const { pipePoints, startTime, duration, schemeName, network } = + parametersState; const [isSelecting, setIsSelecting] = useState<boolean>(false); const [highlightLayer, setHighlightLayer] = @@ -54,6 +81,17 @@ const AnalysisParameters: React.FC = () => { const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [analyzing, setAnalyzing] = useState<boolean>(false); + const setPipePoints = useCallback( + (next: PipePoint[] | ((previous: PipePoint[]) => PipePoint[])) => { + setParametersState((previous) => ({ + ...previous, + pipePoints: + typeof next === "function" ? next(previous.pipePoints) : next, + })); + }, + [setParametersState], + ); + // 检查是否所有必要参数都已填写 const isFormValid = pipePoints.length > 0 && @@ -190,6 +228,18 @@ const AnalysisParameters: React.FC = () => { }); }, [highlightFeatures, highlightLayer]); + useEffect(() => { + if (highlightFeatures.length > 0 || pipePoints.length === 0) return; + queryFeaturesByIds( + pipePoints.map((pipe) => pipe.id), + "geo_pipes_mat", + ).then((features) => { + if (features.length > 0) { + setHighlightFeatures(features); + } + }); + }, [highlightFeatures.length, pipePoints]); + // 同步高亮要素和爆管点信息 useEffect(() => { setPipePoints((prevPipes) => { @@ -211,12 +261,11 @@ const AnalysisParameters: React.FC = () => { id: properties.id, diameter: properties.diameter || 0, area: 15, - feature: feature, }; }); return [...filtered, ...newPipes]; }); - }, [highlightFeatures]); + }, [highlightFeatures, setPipePoints]); // 开始选择管道 const handleStartSelection = () => { @@ -237,6 +286,7 @@ const AnalysisParameters: React.FC = () => { }; const handleRemovePipe = (id: string) => { + setPipePoints((prev) => prev.filter((pipe) => pipe.id !== id)); // 从高亮features中移除 setHighlightFeatures((prev) => prev.filter((f) => f.getProperties().id !== id), @@ -425,7 +475,7 @@ const AnalysisParameters: React.FC = () => { <DateTimePicker value={startTime} onChange={(value) => - value && dayjs.isDayjs(value) && setStartTime(value) + value && dayjs.isDayjs(value) && setParameterField("startTime", value) } format="YYYY-MM-DD HH:mm" slotProps={{ @@ -452,7 +502,7 @@ const AnalysisParameters: React.FC = () => { size="small" type="number" value={duration} - onChange={(e) => setDuration(parseInt(e.target.value) || 0)} + onChange={(e) => setParameterField("duration", parseInt(e.target.value) || 0)} placeholder="输入持续时长" /> </Box> @@ -466,7 +516,7 @@ const AnalysisParameters: React.FC = () => { fullWidth size="small" value={schemeName} - onChange={(e) => setSchemeName(e.target.value)} + onChange={(e) => setParameterField("schemeName", e.target.value)} placeholder="输入方案名称" /> </Box> diff --git a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx index 5f7302a..f305a34 100644 --- a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx +++ b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx @@ -18,10 +18,19 @@ import { MyLocation as MyLocationIcon, Handyman as HandymanIcon, } from "@mui/icons-material"; -import AnalysisParameters from "./AnalysisParameters"; -import SchemeQuery from "./SchemeQuery"; +import AnalysisParameters, { + createBurstAnalysisParametersState, + type BurstAnalysisParametersState, +} from "./AnalysisParameters"; +import SchemeQuery, { + createBurstSchemeQueryState, + type BurstSchemeQueryState, +} from "./SchemeQuery"; import LocationResults from "./LocationResults"; -import ValveIsolation from "./ValveIsolation"; +import ValveIsolation, { + createValveIsolationState, + type ValveIsolationState, +} from "./ValveIsolation"; import { api } from "@/lib/api"; import { config } from "@config/config"; import { useNotification } from "@refinedev/core"; @@ -58,6 +67,11 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ }) => { const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); + const [analysisState, setAnalysisState] = + useState<BurstAnalysisParametersState>(createBurstAnalysisParametersState); + const [queryState, setQueryState] = useState<BurstSchemeQueryState>( + createBurstSchemeQueryState, + ); // 持久化方案查询结果 const [schemes, setSchemes] = useState<SchemeRecord[]>([]); @@ -66,6 +80,8 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ // 关阀分析结果和加载状态 const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false); const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null); + const [valveIsolationState, setValveIsolationState] = + useState<ValveIsolationState>(createValveIsolationState); const { open } = useNotification(); @@ -224,7 +240,10 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ {/* Tab 内容 */} <TabPanel value={currentTab} index={0}> - <AnalysisParameters /> + <AnalysisParameters + state={analysisState} + onStateChange={setAnalysisState} + /> </TabPanel> <TabPanel value={currentTab} index={1}> @@ -232,6 +251,8 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ schemes={schemes} onSchemesChange={setSchemes} onLocate={handleLocateScheme} + state={queryState} + onStateChange={setQueryState} /> </TabPanel> @@ -247,6 +268,8 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ result={valveAnalysisResult} onLoadingChange={setValveAnalysisLoading} onResultChange={setValveAnalysisResult} + state={valveIsolationState} + onStateChange={setValveIsolationState} /> </TabPanel> </Box> diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index 81087c1..d2ab883 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -30,6 +30,7 @@ import { api } from "@/lib/api"; import moment from "moment"; import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { useData, useMap } from "@components/olmap/core/MapComponent"; @@ -56,31 +57,57 @@ interface SchemeQueryProps { onSchemesChange?: (schemes: SchemeRecord[]) => void; onLocate?: (scheme: SchemeRecord) => void; network?: string; + state?: BurstSchemeQueryState; + onStateChange?: (state: BurstSchemeQueryState) => void; } const SCHEME_TYPE = "burst_analysis"; +export interface BurstSchemeQueryState { + queryAll: boolean; + queryDate: Dayjs | null; + showTimeline: boolean; + selectedDate: Date | undefined; + timeRange: { start: Date; end: Date } | undefined; + expandedId: number | null; +} + +export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({ + queryAll: true, + queryDate: dayjs(new Date()), + showTimeline: false, + selectedDate: undefined, + timeRange: undefined, + expandedId: null, +}); + const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemes: externalSchemes, onSchemesChange, onLocate, network = NETWORK_NAME, + state, + onStateChange, }) => { - const [queryAll, setQueryAll] = useState<boolean>(true); - const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date())); + const [queryState, setQueryState, setQueryField] = useControllableObjectState( + state, + onStateChange, + createBurstSchemeQueryState(), + ); + const { + queryAll, + queryDate, + showTimeline, + selectedDate, + timeRange, + expandedId, + } = queryState; const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); - // 时间轴相关状态 - const [showTimeline, setShowTimeline] = useState(false); - const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined); - const [timeRange, setTimeRange] = useState< - { start: Date; end: Date } | undefined - >(); const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); - const [expandedId, setExpandedId] = useState<number | null>(null); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素 const { open } = useNotification(); @@ -181,7 +208,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const scheme = filteredSchemes.find((s) => s.id === id); if (!scheme) return; - setShowTimeline(true); + setQueryState((previous) => ({ ...previous, showTimeline: true })); // 计算时间范围 const schemeDate = scheme.startTime ? new Date(scheme.startTime) @@ -191,8 +218,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const end = new Date( start.getTime() + scheme.schemeDetail.modify_total_duration * 1000, ); - setSelectedDate(schemeDate); - setTimeRange({ start, end }); + setQueryState((previous) => ({ + ...previous, + showTimeline: true, + selectedDate: schemeDate, + timeRange: { start, end }, + })); } setSchemeName?.(scheme.schemeName); handleLocatePipes(scheme.schemeDetail?.burst_ID || []); @@ -324,7 +355,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryAll(e.target.checked)} + onChange={(e) => setQueryField("queryAll", e.target.checked)} size="small" /> } @@ -338,7 +369,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <DatePicker value={queryDate} onChange={(value) => - value && dayjs.isDayjs(value) && setQueryDate(value) + value && dayjs.isDayjs(value) && setQueryField("queryDate", value) } format="YYYY-MM-DD" disabled={queryAll} @@ -450,13 +481,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ expandedId === scheme.id ? "收起详情" : "查看详情" } > - <IconButton - size="small" - onClick={() => - setExpandedId( - expandedId === scheme.id ? null : scheme.id, - ) - } + <IconButton + size="small" + onClick={() => + setQueryField( + "expandedId", + expandedId === scheme.id ? null : scheme.id, + ) + } color="primary" className="p-1" > diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index 3e58e18..11ddd97 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -51,6 +51,7 @@ import { } from "@turf/turf"; import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; interface ValveIsolationProps { initialPipeIds?: string[]; @@ -60,8 +61,24 @@ interface ValveIsolationProps { result?: ValveIsolationResult | null; onLoadingChange?: (loading: boolean) => void; onResultChange?: (result: ValveIsolationResult | null) => void; + state?: ValveIsolationState; + onStateChange?: (state: ValveIsolationState) => void; } +export interface ValveIsolationState { + selectedPipeId: string | null; + activeStep: number; + expandedResult: boolean; + disabledValves: string[]; +} + +export const createValveIsolationState = (): ValveIsolationState => ({ + selectedPipeId: null, + activeStep: 0, + expandedResult: true, + disabledValves: [], +}); + const ValveIsolation: React.FC<ValveIsolationProps> = ({ initialPipeIds = [], shouldFetch = false, @@ -70,6 +87,8 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ result: externalResult, onLoadingChange, onResultChange, + state, + onStateChange, }) => { const [internalLoading, setInternalLoading] = useState(false); const [internalResult, setInternalResult] = @@ -82,12 +101,41 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ const setLoading = onLoadingChange || setInternalLoading; const setResult = onResultChange || setInternalResult; - const [selectedPipeId, setSelectedPipeId] = useState<string | null>(null); + const [flowState, setFlowState, setFlowField] = useControllableObjectState( + state, + onStateChange, + createValveIsolationState(), + ); + const { selectedPipeId, activeStep, expandedResult, disabledValves } = + flowState; const [highlightFeature, setHighlightFeature] = useState<Feature | null>(null); const [isSelecting, setIsSelecting] = useState(false); - const [activeStep, setActiveStep] = useState(0); - const [expandedResult, setExpandedResult] = useState(true); - const [disabledValves, setDisabledValves] = useState<string[]>([]); + + const setSelectedPipeId = useCallback( + (value: string | null) => setFlowField("selectedPipeId", value), + [setFlowField], + ); + + const setActiveStep = useCallback( + (value: number) => setFlowField("activeStep", value), + [setFlowField], + ); + + const setExpandedResult = useCallback( + (value: boolean) => setFlowField("expandedResult", value), + [setFlowField], + ); + + const setDisabledValves = useCallback( + (next: string[] | ((previous: string[]) => string[])) => { + setFlowState((previous) => ({ + ...previous, + disabledValves: + typeof next === "function" ? next(previous.disabledValves) : next, + })); + }, + [setFlowState], + ); const { open } = useNotification(); const map = useMap(); @@ -119,7 +167,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ } } }, - [isSelecting, map, open, setResult], + [isSelecting, map, open, setResult, setSelectedPipeId], ); useEffect(() => { @@ -135,6 +183,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ }; }, [map, isSelecting, handleMapClick]); + useEffect(() => { + if (!selectedPipeId || highlightFeature) return; + queryFeaturesByIds([selectedPipeId], "geo_pipes_mat").then((features) => { + setHighlightFeature(features[0] ?? null); + }); + }, [highlightFeature, selectedPipeId]); + const clearSelectedPipe = () => { setSelectedPipeId(null); setHighlightFeature(null); @@ -297,7 +352,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ setLoading(false); } }, - [open, setLoading, setResult], + [open, setActiveStep, setDisabledValves, setLoading, setResult], ); // 监听外部传入的分析请求 @@ -319,7 +374,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ onFetchComplete(); } } - }, [shouldFetch, initialPipeIds, fetchAnalysis, onFetchComplete]); + }, [shouldFetch, initialPipeIds, fetchAnalysis, onFetchComplete, setSelectedPipeId]); // 初始化高亮图层 useEffect(() => { diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index d47deba..a7f340f 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -24,21 +24,57 @@ import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style"; import Feature from "ol/Feature"; -import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; +import { + handleMapClickSelectFeatures as mapClickSelectFeatures, + queryFeaturesByIds, +} from "@/utils/mapQueryService"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; -const AnalysisParameters: React.FC = () => { +export interface ContaminantAnalysisParametersState { + schemeName: string; + startTime: Dayjs | null; + sourceNode: string; + concentration: number; + duration: number; + pattern: string; +} + +export const createContaminantAnalysisParametersState = + (): ContaminantAnalysisParametersState => ({ + schemeName: "WQ_" + new Date().getTime(), + startTime: dayjs(new Date()), + sourceNode: "", + concentration: 100, + duration: 3600, + pattern: "", + }); + +interface AnalysisParametersProps { + state?: ContaminantAnalysisParametersState; + onStateChange?: (state: ContaminantAnalysisParametersState) => void; +} + +const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ + state, + onStateChange, +}) => { const map = useMap(); const { open } = useNotification(); const network = NETWORK_NAME; - const [schemeName, setSchemeName] = useState<string>( - "WQ_" + new Date().getTime(), + const [parametersState, , setFormField] = useControllableObjectState( + state, + onStateChange, + createContaminantAnalysisParametersState(), ); - const [startTime, setStartTime] = useState<Dayjs | null>(dayjs(new Date())); - const [sourceNode, setSourceNode] = useState<string>(""); - const [concentration, setConcentration] = useState<number>(100); - const [duration, setDuration] = useState<number>(3600); - const [pattern, setPattern] = useState<string>(""); + const { + schemeName, + startTime, + sourceNode, + concentration, + duration, + pattern, + } = parametersState; const [isSelecting, setIsSelecting] = useState<boolean>(false); const [submitting, setSubmitting] = useState<boolean>(false); @@ -77,12 +113,12 @@ const AnalysisParameters: React.FC = () => { const id = feature.getProperties().id; if (!id) return; - setSourceNode(id); + setFormField("sourceNode", id); setHighlightFeature(feature); setIsSelecting(false); map.un("click", handleMapClickSelectFeatures); }, - [map, open], + [map, open, setFormField], ); useEffect(() => { @@ -144,6 +180,17 @@ const AnalysisParameters: React.FC = () => { } }, [highlightFeature, highlightLayer]); + useEffect(() => { + if (!sourceNode) { + setHighlightFeature(null); + return; + } + if (highlightFeature) return; + queryFeaturesByIds([sourceNode], "geo_junctions_mat").then((features) => { + setHighlightFeature(features[0] ?? null); + }); + }, [highlightFeature, sourceNode]); + const handleStartSelection = () => { if (!map) return; setIsSelecting(true); @@ -157,7 +204,7 @@ const AnalysisParameters: React.FC = () => { }; const handleClearSource = () => { - setSourceNode(""); + setFormField("sourceNode", ""); setHighlightFeature(null); }; @@ -176,7 +223,7 @@ const AnalysisParameters: React.FC = () => { : ""; try { if (!pattern) { - setPattern("CONSTANT"); + setFormField("pattern", "CONSTANT"); console.log("默认设置 pattern 为 CONSTANT"); } const params = { @@ -310,7 +357,7 @@ const AnalysisParameters: React.FC = () => { <DateTimePicker value={startTime} onChange={(value) => - value && dayjs.isDayjs(value) && setStartTime(value) + value && dayjs.isDayjs(value) && setFormField("startTime", value) } format="YYYY-MM-DD HH:mm" slotProps={{ @@ -335,7 +382,7 @@ const AnalysisParameters: React.FC = () => { fullWidth size="small" value={schemeName} - onChange={(e) => setSchemeName(e.target.value)} + onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="输入方案名称" /> </Box> @@ -349,7 +396,9 @@ const AnalysisParameters: React.FC = () => { size="small" type="number" value={concentration} - onChange={(e) => setConcentration(parseFloat(e.target.value) || 0)} + onChange={(e) => + setFormField("concentration", parseFloat(e.target.value) || 0) + } placeholder="输入浓度" /> </Box> @@ -363,7 +412,9 @@ const AnalysisParameters: React.FC = () => { size="small" type="number" value={duration} - onChange={(e) => setDuration(parseInt(e.target.value, 10) || 0)} + onChange={(e) => + setFormField("duration", parseInt(e.target.value, 10) || 0) + } placeholder="输入持续时长" /> </Box> @@ -376,7 +427,7 @@ const AnalysisParameters: React.FC = () => { fullWidth size="small" value={pattern} - onChange={(e) => setPattern(e.target.value)} + onChange={(e) => setFormField("pattern", e.target.value)} placeholder="可选,输入 pattern 名称,默认为 CONSTANT" /> </Box> diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index c4eca7b..ceac84c 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -30,6 +30,7 @@ import moment from "moment"; import { useNotification } from "@refinedev/core"; import { config, NETWORK_NAME } from "@config/config"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useData, useMap } from "@components/olmap/core/MapComponent"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; @@ -45,32 +46,60 @@ interface SchemeQueryProps { onSchemesChange?: (schemes: ContaminantSchemeRecord[]) => void; onViewResults?: () => void; network?: string; + state?: ContaminantSchemeQueryState; + onStateChange?: (state: ContaminantSchemeQueryState) => void; } const SCHEME_TYPE = "contaminant_analysis"; +export interface ContaminantSchemeQueryState { + queryAll: boolean; + queryDate: Dayjs | null; + showTimeline: boolean; + selectedDate: Date | undefined; + timeRange: { start: Date; end: Date } | undefined; + expandedId: number | null; +} + +export const createContaminantSchemeQueryState = + (): ContaminantSchemeQueryState => ({ + queryAll: true, + queryDate: dayjs(new Date()), + showTimeline: false, + selectedDate: undefined, + timeRange: undefined, + expandedId: null, + }); + const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemes: externalSchemes, onSchemesChange, onViewResults, network = NETWORK_NAME, + state, + onStateChange, }) => { - const [queryAll, setQueryAll] = useState<boolean>(true); - const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date())); + const [queryState, setQueryState, setQueryField] = useControllableObjectState( + state, + onStateChange, + createContaminantSchemeQueryState(), + ); + const { + queryAll, + queryDate, + showTimeline, + selectedDate, + timeRange, + expandedId, + } = queryState; const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); - const [showTimeline, setShowTimeline] = useState(false); - const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined); - const [timeRange, setTimeRange] = useState< - { start: Date; end: Date } | undefined - >(); const [internalSchemes, setInternalSchemes] = useState< ContaminantSchemeRecord[] >([]); const [loading, setLoading] = useState<boolean>(false); - const [expandedId, setExpandedId] = useState<number | null>(null); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); const { open } = useNotification(); @@ -256,7 +285,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const scheme = filteredSchemes.find((s) => s.id === id); if (!scheme) return; - setShowTimeline(true); + setQueryState((previous) => ({ ...previous, showTimeline: true })); const schemeDate = scheme.startTime ? new Date(scheme.startTime) : undefined; @@ -265,8 +294,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const end = new Date( start.getTime() + scheme.schemeDetail.duration * 1000, ); - setSelectedDate(schemeDate); - setTimeRange({ start, end }); + setQueryState((previous) => ({ + ...previous, + showTimeline: true, + selectedDate: schemeDate, + timeRange: { start, end }, + })); } setSchemeName?.(scheme.schemeName); if (scheme.schemeDetail?.source) { @@ -296,7 +329,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryAll(e.target.checked)} + onChange={(e) => setQueryField("queryAll", e.target.checked)} size="small" /> } @@ -310,7 +343,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <DatePicker value={queryDate} onChange={(value) => - value && dayjs.isDayjs(value) && setQueryDate(value) + value && dayjs.isDayjs(value) && setQueryField("queryDate", value) } format="YYYY-MM-DD" disabled={queryAll} @@ -422,7 +455,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <IconButton size="small" onClick={() => - setExpandedId( + setQueryField( + "expandedId", expandedId === scheme.id ? null : scheme.id, ) } diff --git a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx index e7709ac..77cfc84 100644 --- a/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx +++ b/src/components/olmap/ContaminantSimulation/WaterQualityPanel.tsx @@ -17,8 +17,14 @@ import { Search as SearchIcon, MyLocation as MyLocationIcon, } from "@mui/icons-material"; -import ContaminantAnalysisParameters from "./AnalysisParameters"; -import ContaminantSchemeQuery from "./SchemeQuery"; +import ContaminantAnalysisParameters, { + createContaminantAnalysisParametersState, + type ContaminantAnalysisParametersState, +} from "./AnalysisParameters"; +import ContaminantSchemeQuery, { + createContaminantSchemeQueryState, + type ContaminantSchemeQueryState, +} from "./SchemeQuery"; import { useData } from "@components/olmap/core/MapComponent"; import { ContaminantSchemeRecord } from "./types"; @@ -34,6 +40,14 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({ const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); const [schemes, setSchemes] = useState<ContaminantSchemeRecord[]>([]); + const [analysisState, setAnalysisState] = + useState<ContaminantAnalysisParametersState>( + createContaminantAnalysisParametersState, + ); + const [queryState, setQueryState] = + useState<ContaminantSchemeQueryState>( + createContaminantSchemeQueryState, + ); const data = useData(); @@ -170,7 +184,10 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({ {/* Tab 内容 */} <TabPanel value={currentTab} index={0}> - <ContaminantAnalysisParameters /> + <ContaminantAnalysisParameters + state={analysisState} + onStateChange={setAnalysisState} + /> </TabPanel> <TabPanel value={currentTab} index={1}> @@ -178,6 +195,8 @@ const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({ schemes={schemes} onSchemesChange={setSchemes} onViewResults={() => setCurrentTab(2)} + state={queryState} + onStateChange={setQueryState} /> </TabPanel> </Box> diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 5815867..cf3a1d1 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -19,27 +19,63 @@ import "dayjs/locale/zh-cn"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { LeakageResultDetail } from "./types"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; interface Props { onResult: (result: LeakageResultDetail) => void; + state?: DMALeakAnalysisParametersState; + onStateChange?: (state: DMALeakAnalysisParametersState) => void; } -const AnalysisParameters: React.FC<Props> = ({ onResult }) => { +export interface DMALeakAnalysisParametersState { + schemeName: string; + dmaCount: number; + startTime: Dayjs | null; + endTime: Dayjs | null; + popSize: number; + maxGen: number; + qSum: number; + advancedOpen: boolean; +} + +export const createDMALeakAnalysisParametersState = + (): DMALeakAnalysisParametersState => ({ + schemeName: `DMA_Leak_${Date.now()}`, + dmaCount: 5, + startTime: dayjs().subtract(2, "hour"), + endTime: dayjs(), + popSize: 10, + maxGen: 50, + qSum: 1440, + advancedOpen: false, + }); + +const AnalysisParameters: React.FC<Props> = ({ + onResult, + state, + onStateChange, +}) => { const { open } = useNotification(); - const [schemeName, setSchemeName] = useState(`DMA_Leak_${Date.now()}`); - const [dmaCount, setDmaCount] = useState<number>(5); - const [startTime, setStartTime] = useState<Dayjs | null>( - dayjs().subtract(2, "hour"), + const [parametersState, , setFormField] = useControllableObjectState( + state, + onStateChange, + createDMALeakAnalysisParametersState(), ); - const [endTime, setEndTime] = useState<Dayjs | null>(dayjs()); - const [popSize, setPopSize] = useState<number>(10); - const [maxGen, setMaxGen] = useState<number>(50); - const [qSum, setQSum] = useState<number>(1440); - const [advancedOpen, setAdvancedOpen] = useState(false); + const { + schemeName, + dmaCount, + startTime, + endTime, + popSize, + maxGen, + qSum, + advancedOpen, + } = parametersState; const [running, setRunning] = useState(false); + const isValid = useMemo(() => { if (!schemeName.trim() || !startTime || !endTime) return false; return startTime.isBefore(endTime) && qSum >= 360; @@ -105,7 +141,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <TextField value={schemeName} - onChange={(e) => setSchemeName(e.target.value)} + onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" fullWidth size="small" @@ -123,11 +159,11 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { const value = Number.parseInt(e.target.value, 10); // Limit between 3 and 10 if (Number.isNaN(value)) { - setDmaCount(5); + setFormField("dmaCount", 5); } else if (value > 10) { - setDmaCount(10); + setFormField("dmaCount", 10); } else { - setDmaCount(Math.max(3, value)); + setFormField("dmaCount", Math.max(3, value)); } }} fullWidth @@ -150,7 +186,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <DateTimePicker value={startTime} - onChange={setStartTime} + onChange={(value) => setFormField("startTime", value)} maxDateTime={endTime ?? undefined} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} @@ -162,7 +198,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { </Typography> <DateTimePicker value={endTime} - onChange={setEndTime} + onChange={(value) => setFormField("endTime", value)} minDateTime={startTime ?? undefined} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} @@ -180,7 +216,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { value={qSum} onChange={(e) => { const value = Number(e.target.value); - setQSum(Number.isNaN(value) ? 1440 : Math.max(360, value)); + setFormField("qSum", Number.isNaN(value) ? 1440 : Math.max(360, value)); }} inputProps={{ min: 360, step: 10 }} /> @@ -195,9 +231,9 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { <Box role="button" tabIndex={0} - onClick={() => setAdvancedOpen((prev) => !prev)} + onClick={() => setFormField("advancedOpen", !advancedOpen)} onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") setAdvancedOpen((prev) => !prev); + if (e.key === "Enter" || e.key === " ") setFormField("advancedOpen", !advancedOpen); }} sx={{ display: "flex", @@ -235,14 +271,14 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => { label="种群规模" size="small" value={popSize} - onChange={(e) => setPopSize(Number(e.target.value))} + onChange={(e) => setFormField("popSize", Number(e.target.value))} /> <TextField type="number" label="最大代数" size="small" value={maxGen} - onChange={(e) => setMaxGen(Number(e.target.value))} + onChange={(e) => setFormField("maxGen", Number(e.target.value))} /> </Box> </Box> diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index f3892d4..1321323 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -19,8 +19,14 @@ import { } from "@mui/icons-material"; import { useMap } from "@components/olmap/core/MapComponent"; import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; -import AnalysisParameters from "./AnalysisParameters"; -import SchemeQuery from "./SchemeQuery"; +import AnalysisParameters, { + createDMALeakAnalysisParametersState, + type DMALeakAnalysisParametersState, +} from "./AnalysisParameters"; +import SchemeQuery, { + createDMALeakSchemeQueryState, + type DMALeakSchemeQueryState, +} from "./SchemeQuery"; import RecognitionResults from "./RecognitionResults"; import { applyJunctionAreaRender } from "./applyJunctionAreaRender"; import { getAreaColor } from "./utils"; @@ -49,6 +55,13 @@ const DMALeakDetectionPanel: React.FC = () => { const [result, setResult] = useState<LeakageResultDetail | null>(null); const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null); const [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]); + const [analysisState, setAnalysisState] = + useState<DMALeakAnalysisParametersState>( + createDMALeakAnalysisParametersState, + ); + const [queryState, setQueryState] = useState<DMALeakSchemeQueryState>( + createDMALeakSchemeQueryState, + ); const drawerWidth = 450; const panelTitle = "DMA 漏损识别"; @@ -196,10 +209,20 @@ const DMALeakDetectionPanel: React.FC = () => { </Tabs> </Box> <TabPanel value={tab} index={0}> - <AnalysisParameters onResult={handleAnalysisResult} /> + <AnalysisParameters + onResult={handleAnalysisResult} + state={analysisState} + onStateChange={setAnalysisState} + /> </TabPanel> <TabPanel value={tab} index={1}> - <SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} /> + <SchemeQuery + onViewResult={handleViewResult} + schemes={schemes} + onSchemesChange={setSchemes} + state={queryState} + onStateChange={setQueryState} + /> </TabPanel> <TabPanel value={tab} index={2}> <RecognitionResults result={result} /> diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index cd709af..39ad5e6 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -23,6 +23,7 @@ import dayjs, { Dayjs } from "dayjs"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; @@ -30,15 +31,38 @@ interface Props { onViewResult: (result: LeakageResultDetail) => void; schemes?: LeakageSchemeRecord[]; onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void; + state?: DMALeakSchemeQueryState; + onStateChange?: (state: DMALeakSchemeQueryState) => void; } -const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => { +export interface DMALeakSchemeQueryState { + queryAll: boolean; + queryDate: Dayjs | null; + expandedId: number | null; +} + +export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({ + queryAll: true, + queryDate: dayjs(), + expandedId: null, +}); + +const SchemeQuery: React.FC<Props> = ({ + onViewResult, + schemes: externalSchemes, + onSchemesChange, + state, + onStateChange, +}) => { const { open } = useNotification(); - const [queryAll, setQueryAll] = useState(true); - const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs()); + const [queryState, , setQueryField] = useControllableObjectState( + state, + onStateChange, + createDMALeakSchemeQueryState(), + ); + const { queryAll, queryDate, expandedId } = queryState; const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]); const [loading, setLoading] = useState(false); - const [expandedId, setExpandedId] = useState<number | null>(null); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; @@ -91,7 +115,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <Checkbox size="small" checked={queryAll} - onChange={(e) => setQueryAll(e.target.checked)} + onChange={(e) => setQueryField("queryAll", e.target.checked)} /> } label={<Typography variant="body2">查询全部</Typography>} @@ -100,7 +124,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <DatePicker value={queryDate} - onChange={setQueryDate} + onChange={(value) => setQueryField("queryDate", value)} disabled={queryAll} format="YYYY-MM-DD" slotProps={{ textField: { size: "small", sx: { width: 200 } } }} @@ -178,7 +202,12 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, <Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}> <IconButton size="small" - onClick={() => setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)} + onClick={() => + setQueryField( + "expandedId", + expandedId === scheme.scheme_id ? null : scheme.scheme_id, + ) + } color="primary" className="p-1" > diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index fd5a2f9..67848e4 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -22,39 +22,82 @@ import { useMap } from "@components/olmap/core/MapComponent"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Style, Stroke, Fill, Circle as CircleStyle } from "ol/style"; -import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; +import { + handleMapClickSelectFeatures as mapClickSelectFeatures, + queryFeaturesByIds, +} from "@/utils/mapQueryService"; import Feature, { FeatureLike } from "ol/Feature"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; -interface ValveItem { +export interface ValveItem { id: string; k: number; - feature?: any; } -const AnalysisParameters: React.FC = () => { +export interface FlushingAnalysisParametersState { + schemeName: string; + valves: ValveItem[]; + drainageNode: string | null; + startTime: Dayjs | null; + flushFlow: number; + duration: number; +} + +export const createFlushingAnalysisParametersState = + (): FlushingAnalysisParametersState => ({ + schemeName: "Flushing_" + new Date().getTime(), + valves: [], + drainageNode: null, + startTime: dayjs(new Date()), + flushFlow: 200, + duration: 3600, + }); + +interface AnalysisParametersProps { + state?: FlushingAnalysisParametersState; + onStateChange?: (state: FlushingAnalysisParametersState) => void; +} + +const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ + state, + onStateChange, +}) => { const map = useMap(); const { open } = useNotification(); - // State - const [schemeName, setSchemeName] = useState<string>( - "Flushing_" + new Date().getTime(), + const [parametersState, setParametersState, setFormField] = useControllableObjectState( + state, + onStateChange, + createFlushingAnalysisParametersState(), ); - const [valves, setValves] = useState<ValveItem[]>([]); - const [drainageNode, setDrainageNode] = useState<string | null>(null); + const { schemeName, valves, drainageNode, startTime, flushFlow, duration } = + parametersState; + const [valveFeatures, setValveFeatures] = useState<Feature[]>([]); const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null); - const [startTime, setStartTime] = useState<Dayjs | null>(dayjs(new Date())); - const [flushFlow, setFlushFlow] = useState<number>(200); - const [duration, setDuration] = useState<number>(3600); - const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none'); const [analyzing, setAnalyzing] = useState<boolean>(false); const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); + const setValves = useCallback( + (next: ValveItem[] | ((previous: ValveItem[]) => ValveItem[])) => { + setParametersState((previous) => ({ + ...previous, + valves: typeof next === "function" ? next(previous.valves) : next, + })); + }, + [setParametersState], + ); + + const setDrainageNode = useCallback( + (value: string | null) => setFormField("drainageNode", value), + [setFormField], + ); + // Map click handler const handleMapClickSelectFeatures = useCallback( async (event: { coordinate: number[] }) => { @@ -83,7 +126,8 @@ const AnalysisParameters: React.FC = () => { }); return prev; } - return [...prev, { id: featureId, k: 1.0, feature }]; + setValveFeatures((features) => [...features, feature]); + return [...prev, { id: featureId, k: 1.0 }]; }); } else if (selectionMode === 'drainage') { @@ -100,7 +144,7 @@ const AnalysisParameters: React.FC = () => { map.un("click", handleMapClickSelectFeatures); } }, - [map, selectionMode, open] + [map, selectionMode, open, setDrainageNode, setValves] ); // Initialize highlight layer @@ -162,9 +206,9 @@ const AnalysisParameters: React.FC = () => { source.clear(); // Add valves - valves.forEach((v) => { - if (v.feature) { - const f = v.feature.clone(); // Clone to avoid modifying original + valveFeatures.forEach((feature) => { + if (feature) { + const f = feature.clone(); // Clone to avoid modifying original f.set("type", "valve"); // Ensure geometry is present (it should be for features from map) if (f.getGeometry()) { @@ -180,7 +224,32 @@ const AnalysisParameters: React.FC = () => { source.addFeature(f); } - }, [highlightLayer, valves, drainageFeature]); + }, [highlightLayer, valveFeatures, drainageFeature]); + + useEffect(() => { + if (valves.length === 0) { + setValveFeatures([]); + return; + } + if (valveFeatures.length > 0) return; + queryFeaturesByIds( + valves.map((valve) => valve.id), + "geo_valves", + ).then((features) => { + setValveFeatures(features); + }); + }, [valveFeatures.length, valves]); + + useEffect(() => { + if (!drainageNode) { + setDrainageFeature(null); + return; + } + if (drainageFeature) return; + queryFeaturesByIds([drainageNode], "geo_junctions").then((features) => { + setDrainageFeature(features[0] ?? null); + }); + }, [drainageFeature, drainageNode]); // Bind click event based on selection mode useEffect(() => { @@ -205,6 +274,9 @@ const AnalysisParameters: React.FC = () => { const handleRemoveValve = (id: string) => { setValves((prev) => prev.filter((v) => v.id !== id)); + setValveFeatures((prev) => + prev.filter((feature) => feature.getProperties().id !== id), + ); }; const handleValveKChange = (id: string, k: string) => { @@ -373,7 +445,7 @@ const AnalysisParameters: React.FC = () => { <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <DateTimePicker value={startTime} - onChange={(newValue) => setStartTime(newValue)} + onChange={(newValue) => setFormField("startTime", newValue)} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} localeText={ @@ -393,7 +465,7 @@ const AnalysisParameters: React.FC = () => { fullWidth size="small" value={schemeName} - onChange={(e) => setSchemeName(e.target.value)} + onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" /> </Box> @@ -408,7 +480,9 @@ const AnalysisParameters: React.FC = () => { size="small" type="number" value={flushFlow} - onChange={(e) => setFlushFlow(parseFloat(e.target.value) || 0)} + onChange={(e) => + setFormField("flushFlow", parseFloat(e.target.value) || 0) + } /> </Box> <Box className="flex-1"> @@ -420,7 +494,9 @@ const AnalysisParameters: React.FC = () => { size="small" type="number" value={duration} - onChange={(e) => setDuration(parseInt(e.target.value) || 0)} + onChange={(e) => + setFormField("duration", parseInt(e.target.value) || 0) + } /> </Box> </Box> diff --git a/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx b/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx index c1bf39a..39f62ca 100644 --- a/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx +++ b/src/components/olmap/FlushingAnalysis/FlushingAnalysisPanel.tsx @@ -17,8 +17,14 @@ import { Search as SearchIcon, } from "@mui/icons-material"; import { MdCleaningServices } from "react-icons/md"; -import AnalysisParameters from "./AnalysisParameters"; -import SchemeQuery from "./SchemeQuery"; +import AnalysisParameters, { + createFlushingAnalysisParametersState, + type FlushingAnalysisParametersState, +} from "./AnalysisParameters"; +import SchemeQuery, { + createFlushingSchemeQueryState, + type FlushingSchemeQueryState, +} from "./SchemeQuery"; import { SchemeRecord } from "./types"; interface TabPanelProps { @@ -53,6 +59,13 @@ const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({ const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); const [schemes, setSchemes] = useState<SchemeRecord[]>([]); + const [analysisState, setAnalysisState] = + useState<FlushingAnalysisParametersState>( + createFlushingAnalysisParametersState, + ); + const [queryState, setQueryState] = useState<FlushingSchemeQueryState>( + createFlushingSchemeQueryState, + ); // Using controlled or uncontrolled state const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; @@ -181,11 +194,19 @@ const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({ {/* Tab Content */} <TabPanel value={currentTab} index={0}> - <AnalysisParameters /> + <AnalysisParameters + state={analysisState} + onStateChange={setAnalysisState} + /> </TabPanel> <TabPanel value={currentTab} index={1}> - <SchemeQuery schemes={schemes} onSchemesChange={setSchemes} /> + <SchemeQuery + schemes={schemes} + onSchemesChange={setSchemes} + state={queryState} + onStateChange={setQueryState} + /> </TabPanel> </Box> </Drawer> diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index fb1aea3..ce17de7 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -32,6 +32,7 @@ import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; import { useData, useMap } from "@components/olmap/core/MapComponent"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; @@ -46,29 +47,58 @@ interface SchemeQueryProps { schemes?: SchemeRecord[]; onSchemesChange?: (schemes: SchemeRecord[]) => void; network?: string; + state?: FlushingSchemeQueryState; + onStateChange?: (state: FlushingSchemeQueryState) => void; } const SCHEME_TYPE = "flushing_analysis"; +export interface FlushingSchemeQueryState { + queryAll: boolean; + queryDate: Dayjs | null; + expandedId: number | null; + showTimeline: boolean; + selectedDate: Date | undefined; + timeRange: { start: Date; end: Date } | undefined; +} + +export const createFlushingSchemeQueryState = + (): FlushingSchemeQueryState => ({ + queryAll: true, + queryDate: dayjs(new Date()), + expandedId: null, + showTimeline: false, + selectedDate: undefined, + timeRange: undefined, + }); + const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemes: externalSchemes, onSchemesChange, network = NETWORK_NAME, + state, + onStateChange, }) => { - const [queryAll, setQueryAll] = useState<boolean>(true); - const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date())); + const [queryState, setQueryState, setQueryField] = useControllableObjectState( + state, + onStateChange, + createFlushingSchemeQueryState(), + ); + const { + queryAll, + queryDate, + expandedId, + showTimeline, + selectedDate, + timeRange, + } = queryState; const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); - const [expandedId, setExpandedId] = useState<number | null>(null); const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); - // Timeline related state - const [showTimeline, setShowTimeline] = useState(false); - const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined); - const [timeRange, setTimeRange] = useState<{ start: Date; end: Date } | undefined>(); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); const { open } = useNotification(); @@ -269,15 +299,19 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ }; const handleViewResults = (scheme: SchemeRecord) => { - setShowTimeline(true); + setQueryState((previous) => ({ ...previous, showTimeline: true })); const schemeDate = scheme.startTime ? new Date(scheme.startTime) : undefined; if (scheme.startTime && scheme.schemeDetail?.duration) { const start = new Date(scheme.startTime); const end = new Date(start.getTime() + scheme.schemeDetail.duration * 1000); - setSelectedDate(schemeDate); - setTimeRange({ start, end }); + setQueryState((previous) => ({ + ...previous, + showTimeline: true, + selectedDate: schemeDate, + timeRange: { start, end }, + })); } setSchemeName?.(scheme.schemeName); @@ -311,7 +345,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryAll(e.target.checked)} + onChange={(e) => setQueryField("queryAll", e.target.checked)} size="small" /> } @@ -325,7 +359,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <DatePicker value={queryDate} onChange={(value) => - value && dayjs.isDayjs(value) && setQueryDate(value) + value && dayjs.isDayjs(value) && setQueryField("queryDate", value) } format="YYYY-MM-DD" disabled={queryAll} @@ -443,13 +477,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ expandedId === scheme.id ? "收起详情" : "查看详情" } > - <IconButton - size="small" - onClick={() => - setExpandedId( - expandedId === scheme.id ? null : scheme.id, - ) - } + <IconButton + size="small" + onClick={() => + setQueryField( + "expandedId", + expandedId === scheme.id ? null : scheme.id, + ) + } color="primary" className="p-1" > diff --git a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx index a609c97..a7e9a95 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx @@ -17,8 +17,14 @@ import { Analytics as AnalyticsIcon, Search as SearchIcon, } from "@mui/icons-material"; -import OptimizationParameters from "./OptimizationParameters"; -import SchemeQuery from "./SchemeQuery"; +import OptimizationParameters, { + createOptimizationParametersState, + type OptimizationParametersState, +} from "./OptimizationParameters"; +import SchemeQuery, { + createMonitoringSchemeQueryState, + type MonitoringSchemeQueryState, +} from "./SchemeQuery"; interface SchemeRecord { id: number; @@ -63,6 +69,11 @@ const MonitoringPlaceOptimizationPanel: React.FC< // 持久化方案查询结果 const [schemes, setSchemes] = useState<SchemeRecord[]>([]); + const [optimizationState, setOptimizationState] = + useState<OptimizationParametersState>(createOptimizationParametersState); + const [queryState, setQueryState] = useState<MonitoringSchemeQueryState>( + createMonitoringSchemeQueryState, + ); // 使用受控或非受控状态 const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; @@ -192,13 +203,18 @@ const MonitoringPlaceOptimizationPanel: React.FC< {/* Tab 内容 */} <TabPanel value={currentTab} index={0}> - <OptimizationParameters /> + <OptimizationParameters + state={optimizationState} + onStateChange={setOptimizationState} + /> </TabPanel> <TabPanel value={currentTab} index={1}> <SchemeQuery schemes={schemes} onSchemesChange={setSchemes} + state={queryState} + onStateChange={setQueryState} onLocate={(id) => { console.log("定位方案:", id); // TODO: 在地图上定位 diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index 962ddb2..3edeea9 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -14,27 +14,53 @@ import { useNotification } from "@refinedev/core"; import { useGetIdentity } from "@refinedev/core"; import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; type IUser = { id: string; name?: string; }; -const OptimizationParameters: React.FC = () => { +export interface OptimizationParametersState { + sensorType: string; + method: string; + sensorCount: number; + minDiameter: number; + schemeName: string; +} + +export const createOptimizationParametersState = + (): OptimizationParametersState => ({ + sensorType: "pressure", + method: "kmeans", + sensorCount: 5, + minDiameter: 5, + schemeName: "Fangan" + new Date().getTime(), + }); + +interface OptimizationParametersProps { + state?: OptimizationParametersState; + onStateChange?: (state: OptimizationParametersState) => void; +} + +const OptimizationParameters: React.FC<OptimizationParametersProps> = ({ + state, + onStateChange, +}) => { const { open } = useNotification(); const { data: user } = useGetIdentity<IUser>(); - // 表单状态 - const [sensorType, setSensorType] = useState<string>("pressure"); - const [method, setMethod] = useState<string>("kmeans"); - const [sensorCount, setSensorCount] = useState<number>(5); - const [minDiameter, setMinDiameter] = useState<number>(5); - const [schemeName, setSchemeName] = useState<string>( - "Fangan" + new Date().getTime() + const [parametersState, , setFormField] = useControllableObjectState( + state, + onStateChange, + createOptimizationParametersState(), ); + const { sensorType, method, sensorCount, minDiameter, schemeName } = + parametersState; const [network] = useState<string>(NETWORK_NAME); const [analyzing, setAnalyzing] = useState<boolean>(false); + // 传感器类型选项 const sensorTypeOptions = [ { value: "pressure", label: "压力" }, @@ -121,7 +147,7 @@ const OptimizationParameters: React.FC = () => { }); // 重置方案名称 - setSchemeName("Fangan" + new Date().getTime()); + setFormField("schemeName", "Fangan" + new Date().getTime()); } else { throw new Error(response.data?.message || "创建失败"); } @@ -153,7 +179,7 @@ const OptimizationParameters: React.FC = () => { fullWidth size="small" value={sensorType} - onChange={(e) => setSensorType(e.target.value)} + onChange={(e) => setFormField("sensorType", e.target.value)} sx={{ "& .MuiOutlinedInput-root": { "&:hover fieldset": { @@ -186,7 +212,7 @@ const OptimizationParameters: React.FC = () => { fullWidth size="small" value={method} - onChange={(e) => setMethod(e.target.value)} + onChange={(e) => setFormField("method", e.target.value)} sx={{ "& .MuiOutlinedInput-root": { "&:hover fieldset": { @@ -219,7 +245,9 @@ const OptimizationParameters: React.FC = () => { size="small" type="number" value={sensorCount} - onChange={(e) => setSensorCount(parseInt(e.target.value) || 0)} + onChange={(e) => + setFormField("sensorCount", parseInt(e.target.value) || 0) + } slotProps={{ htmlInput: { min: 1 }, }} @@ -249,7 +277,9 @@ const OptimizationParameters: React.FC = () => { size="small" type="number" value={minDiameter} - onChange={(e) => setMinDiameter(parseInt(e.target.value) || 0)} + onChange={(e) => + setFormField("minDiameter", parseInt(e.target.value) || 0) + } slotProps={{ htmlInput: { min: 0 }, }} @@ -278,7 +308,7 @@ const OptimizationParameters: React.FC = () => { fullWidth size="small" value={schemeName} - onChange={(e) => setSchemeName(e.target.value)} + onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" sx={{ "& .MuiOutlinedInput-root": { diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index cab5d67..97fe3c0 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -30,6 +30,7 @@ import { config, NETWORK_NAME } from "@config/config"; import { useNotification } from "@refinedev/core"; import { useMap } from "@components/olmap/core/MapComponent"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; @@ -62,19 +63,39 @@ interface SchemeQueryProps { onSchemesChange?: (schemes: SchemeRecord[]) => void; onLocate?: (id: number) => void; network?: string; + state?: MonitoringSchemeQueryState; + onStateChange?: (state: MonitoringSchemeQueryState) => void; } +export interface MonitoringSchemeQueryState { + queryAll: boolean; + queryDate: Dayjs | null; + expandedId: number | null; +} + +export const createMonitoringSchemeQueryState = + (): MonitoringSchemeQueryState => ({ + queryAll: true, + queryDate: dayjs(new Date()), + expandedId: null, + }); + const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemes: externalSchemes, onSchemesChange, onLocate, network = NETWORK_NAME, + state, + onStateChange, }) => { - const [queryAll, setQueryAll] = useState<boolean>(true); - const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date())); + const [queryState, , setQueryField] = useControllableObjectState( + state, + onStateChange, + createMonitoringSchemeQueryState(), + ); + const { queryAll, queryDate, expandedId } = queryState; const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); - const [expandedId, setExpandedId] = useState<number | null>(null); const { open } = useNotification(); const map = useMap(); @@ -228,7 +249,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ // 查看详情(展开/收起) const handleViewDetails = (id: number) => { - setExpandedId(expandedId === id ? null : id); + setQueryField("expandedId", expandedId === id ? null : id); }; // 保存方案(示例功能) @@ -250,7 +271,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryAll(e.target.checked)} + onChange={(e) => setQueryField("queryAll", e.target.checked)} size="small" /> } @@ -264,7 +285,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <DatePicker value={queryDate} onChange={(value) => - value && dayjs.isDayjs(value) && setQueryDate(value) + value && dayjs.isDayjs(value) && setQueryField("queryDate", value) } format="YYYY-MM-DD" disabled={queryAll} @@ -375,7 +396,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <IconButton size="small" onClick={() => - setExpandedId( + setQueryField( + "expandedId", expandedId === scheme.id ? null : scheme.id, ) } diff --git a/src/components/olmap/core/useControllableState.ts b/src/components/olmap/core/useControllableState.ts new file mode 100644 index 0000000..729a849 --- /dev/null +++ b/src/components/olmap/core/useControllableState.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useControllableState = <T,>( + externalValue: T | undefined, + onExternalChange: ((value: T) => void) | undefined, + defaultValue: T, +) => { + const [internalValue, setInternalValue] = useState<T>(defaultValue); + const value = externalValue !== undefined ? externalValue : internalValue; + const valueRef = useRef(value); + + useEffect(() => { + valueRef.current = value; + }, [value]); + + const setValue = useCallback( + (next: T | ((previous: T) => T)) => { + const nextValue = + typeof next === "function" + ? (next as (previous: T) => T)(valueRef.current) + : next; + valueRef.current = nextValue; + if (externalValue === undefined) { + setInternalValue(nextValue); + } + onExternalChange?.(nextValue); + }, + [externalValue, onExternalChange], + ); + + return [value, setValue] as const; +}; + +export const useControllableObjectState = <T extends object>( + externalValue: T | undefined, + onExternalChange: ((value: T) => void) | undefined, + defaultValue: T, +) => { + const [value, setValue] = useControllableState( + externalValue, + onExternalChange, + defaultValue, + ); + + const setField = useCallback( + <K extends keyof T>(key: K, nextValue: T[K]) => { + setValue((previous) => ({ ...previous, [key]: nextValue })); + }, + [setValue], + ); + + return [value, setValue, setField] as const; +}; -- 2.54.0 From 14c76231d51f13fff2168981cd7befbaf5a60539 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 9 Jul 2026 14:50:33 +0800 Subject: [PATCH 215/281] fix(map): prevent controllable state loops --- .../olmap/core/useControllableState.test.ts | 42 +++++++++++++++++++ .../olmap/core/useControllableState.ts | 23 +++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 src/components/olmap/core/useControllableState.test.ts diff --git a/src/components/olmap/core/useControllableState.test.ts b/src/components/olmap/core/useControllableState.test.ts new file mode 100644 index 0000000..1551882 --- /dev/null +++ b/src/components/olmap/core/useControllableState.test.ts @@ -0,0 +1,42 @@ +import { act, renderHook } from "@testing-library/react"; + +import { useControllableObjectState } from "./useControllableState"; + +describe("useControllableObjectState", () => { + it("keeps controlled setters stable when the external value changes", () => { + const onChange = jest.fn(); + const { result, rerender } = renderHook( + ({ value }: { value: { count: number } }) => + useControllableObjectState(value, onChange, { count: 0 }), + { + initialProps: { value: { count: 0 } }, + }, + ); + + const initialSetValue = result.current[1]; + const initialSetField = result.current[2]; + + act(() => { + result.current[2]("count", 1); + }); + expect(onChange).toHaveBeenCalledWith({ count: 1 }); + + rerender({ value: { count: 1 } }); + + expect(result.current[1]).toBe(initialSetValue); + expect(result.current[2]).toBe(initialSetField); + }); + + it("does not publish unchanged object fields", () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useControllableObjectState({ count: 1 }, onChange, { count: 0 }), + ); + + act(() => { + result.current[2]("count", 1); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/olmap/core/useControllableState.ts b/src/components/olmap/core/useControllableState.ts index 729a849..6abeb6f 100644 --- a/src/components/olmap/core/useControllableState.ts +++ b/src/components/olmap/core/useControllableState.ts @@ -10,10 +10,14 @@ export const useControllableState = <T,>( const [internalValue, setInternalValue] = useState<T>(defaultValue); const value = externalValue !== undefined ? externalValue : internalValue; const valueRef = useRef(value); + const isControlledRef = useRef(externalValue !== undefined); + const onExternalChangeRef = useRef(onExternalChange); useEffect(() => { valueRef.current = value; - }, [value]); + isControlledRef.current = externalValue !== undefined; + onExternalChangeRef.current = onExternalChange; + }, [externalValue, onExternalChange, value]); const setValue = useCallback( (next: T | ((previous: T) => T)) => { @@ -21,13 +25,18 @@ export const useControllableState = <T,>( typeof next === "function" ? (next as (previous: T) => T)(valueRef.current) : next; + + if (Object.is(valueRef.current, nextValue)) { + return; + } + valueRef.current = nextValue; - if (externalValue === undefined) { + if (!isControlledRef.current) { setInternalValue(nextValue); } - onExternalChange?.(nextValue); + onExternalChangeRef.current?.(nextValue); }, - [externalValue, onExternalChange], + [], ); return [value, setValue] as const; @@ -46,7 +55,11 @@ export const useControllableObjectState = <T extends object>( const setField = useCallback( <K extends keyof T>(key: K, nextValue: T[K]) => { - setValue((previous) => ({ ...previous, [key]: nextValue })); + setValue((previous) => + Object.is(previous[key], nextValue) + ? previous + : { ...previous, [key]: nextValue }, + ); }, [setValue], ); -- 2.54.0 From adb53d9a13e8a41e9b01b0d86cb44be80ceac886 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 10 Jul 2026 14:29:27 +0800 Subject: [PATCH 216/281] feat(chat): add selection-based speech playback --- src/components/chat/AgentTurn.test.tsx | 115 +++++++++++ src/components/chat/AgentTurn.tsx | 269 ++++++++++++++++++------- 2 files changed, 311 insertions(+), 73 deletions(-) create mode 100644 src/components/chat/AgentTurn.test.tsx diff --git a/src/components/chat/AgentTurn.test.tsx b/src/components/chat/AgentTurn.test.tsx new file mode 100644 index 0000000..d84cfc2 --- /dev/null +++ b/src/components/chat/AgentTurn.test.tsx @@ -0,0 +1,115 @@ +/* eslint-disable @next/next/no-img-element */ +import "@testing-library/jest-dom"; +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { AgentTurn } from "./AgentTurn"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => ( + <img {...props} alt={props.alt ?? ""} /> + ), +})); + +jest.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, + motion: { + div: ({ + children, + animate: _animate, + exit: _exit, + initial: _initial, + transition: _transition, + ...props + }: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => ( + <div {...props}>{children}</div> + ), + span: ({ + children, + animate: _animate, + transition: _transition, + ...props + }: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => ( + <span {...props}>{children}</span> + ), + }, +})); + +jest.mock("./AgentMarkdownBlock", () => ({ + MarkdownBlock: ({ children }: { children: string }) => ( + <div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div> + ), + normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""), +})); + +describe("AgentTurn speech selection", () => { + it("shows a floating action and reads from the selected text", async () => { + const content = "第一段内容。\n\n第二段内容。"; + const speechText = "第一段内容。\n第二段内容。"; + const onSpeak = jest.fn(); + const removeAllRanges = jest.fn(); + + render( + <AgentTurn + message={{ id: "assistant-1", role: "assistant", content }} + isStreaming={false} + messageSpeechState="idle" + onSpeak={onSpeak} + onPause={jest.fn()} + onResume={jest.fn()} + onStopSpeech={jest.fn()} + isTtsSupported + onCreateBranch={jest.fn()} + onReplyPermission={jest.fn()} + onReplyQuestion={jest.fn()} + onRejectQuestion={jest.fn()} + />, + ); + + const selectedParagraph = screen.getByText("第二段内容。"); + const selectedTextNode = selectedParagraph.firstChild as Text; + const range = { + commonAncestorContainer: selectedTextNode, + getBoundingClientRect: () => ({ + width: 72, + height: 20, + top: 120, + right: 172, + bottom: 140, + left: 100, + x: 100, + y: 120, + toJSON: () => ({}), + }), + } as unknown as Range; + const selection = { + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => range, + toString: () => "第二段", + removeAllRanges, + } as unknown as Selection; + + jest.spyOn(window, "getSelection").mockReturnValue(selection); + jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(0); + return 1; + }); + + fireEvent.pointerUp(selectedParagraph); + + const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" }); + fireEvent.click(speechAction); + + await waitFor(() => { + expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, { + startOffset: speechText.indexOf("第二段"), + }); + }); + expect(removeAllRanges).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 0e0965e..c4c78d4 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -2,13 +2,16 @@ import Image from "next/image"; import React, { useMemo } from "react"; -import { AnimatePresence, motion } from "framer-motion"; +import { motion } from "framer-motion"; import { Avatar, Box, CircularProgress, + Button, + Grow, IconButton, Paper, + Popper, Stack, Tooltip, Typography, @@ -41,6 +44,48 @@ import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import StopRounded from "@mui/icons-material/StopRounded"; +const floatingActionSurfaceSx = { + display: "flex", + gap: 0.5, + p: 0.5, + borderRadius: "16px", + bgcolor: alpha("#fff", 0.8), + backdropFilter: "blur(16px)", + border: `1px solid ${alpha("#fff", 0.9)}`, + boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`, + overflow: "hidden", +} as const; + +const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const; +const floatingIconButtonSx = { + width: 28, + height: 28, + color: "text.secondary", + "&:hover": { + color: "#00acc1", + bgcolor: alpha("#00acc1", 0.1), + }, +} as const; +const floatingSpeechButtonSx = { + minHeight: 34, + px: 1.25, + color: "text.primary", + fontSize: 13, + fontWeight: 700, + letterSpacing: 0, + whiteSpace: "nowrap", + borderRadius: "12px", + "&:hover": { + bgcolor: alpha("#00acc1", 0.1), + color: "#00acc1", + }, +} as const; + +type SpeechSelection = { + startOffset: number; + anchorRect: DOMRect; +}; + type AgentTurnProps = { message: Message; isStreaming: boolean; @@ -180,10 +225,7 @@ export const AgentTurn = React.memo( const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const [isHovered, setIsHovered] = React.useState(false); const answerContentRef = React.useRef<HTMLDivElement | null>(null); - const [selectedSpeechStart, setSelectedSpeechStart] = React.useState<{ - offset: number; - preview: string; - } | null>(null); + const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null); const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", ) ?? false; @@ -203,39 +245,94 @@ export const AgentTurn = React.memo( () => stripMarkdown(answerContent), [answerContent], ); - const handleCaptureSpeechSelection = React.useCallback(() => { + const captureSpeechSelection = React.useCallback(() => { + if (!isTtsSupported || isStreamingAssistant) { + setSpeechSelection(null); + return; + } + const selection = window.getSelection(); const container = answerContentRef.current; if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) { + setSpeechSelection(null); return; } const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { + setSpeechSelection(null); return; } const selectedText = selection.toString(); const startOffset = findSpeechSelectionStartOffset(speechText, selectedText); if (startOffset === null) { - setSelectedSpeechStart(null); + setSpeechSelection(null); return; } - const preview = selectedText.replace(/\s+/g, " ").trim().slice(0, 24); - setSelectedSpeechStart({ - offset: startOffset, - preview: preview.length === 24 ? `${preview}...` : preview, - }); - }, [speechText]); + const anchorRect = range.getBoundingClientRect(); + if (anchorRect.width === 0 && anchorRect.height === 0) { + setSpeechSelection(null); + return; + } + + setSpeechSelection({ startOffset, anchorRect }); + }, [isStreamingAssistant, isTtsSupported, speechText]); + const handleCaptureSpeechSelection = React.useCallback(() => { + window.requestAnimationFrame(captureSpeechSelection); + }, [captureSpeechSelection]); React.useEffect(() => { - setSelectedSpeechStart(null); + setSpeechSelection(null); }, [message.id, speechText]); - const handleSpeakFromCurrentStart = () => { - onSpeak(message.id, speechText, { - startOffset: selectedSpeechStart?.offset ?? 0, - }); + React.useEffect(() => { + if (!speechSelection) return; + + const closeSpeechSelection = () => setSpeechSelection(null); + const handleSelectionChange = () => { + if (window.getSelection()?.isCollapsed) { + closeSpeechSelection(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeSpeechSelection(); + } + }; + + document.addEventListener("selectionchange", handleSelectionChange); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("resize", closeSpeechSelection); + window.addEventListener("scroll", closeSpeechSelection, true); + + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("resize", closeSpeechSelection); + window.removeEventListener("scroll", closeSpeechSelection, true); + }; + }, [speechSelection]); + const handleSpeakMessage = () => { + onSpeak(message.id, speechText); }; + const handleSpeakFromSelection = () => { + if (!speechSelection) return; + + onSpeak(message.id, speechText, { + startOffset: speechSelection.startOffset, + }); + window.getSelection()?.removeAllRanges(); + setSpeechSelection(null); + }; + const speechSelectionAnchor = React.useMemo( + () => speechSelection + ? { + getBoundingClientRect: () => speechSelection.anchorRect, + } + : null, + [speechSelection], + ); + const isSpeechSelectionOpen = Boolean(speechSelection); const contentSegments: ContentSegment[] = useMemo( () => !isUser && !isErrorMessage @@ -385,9 +482,8 @@ export const AgentTurn = React.memo( <Box ref={answerContentRef} - onMouseUp={handleCaptureSpeechSelection} + onPointerUp={handleCaptureSpeechSelection} onKeyUp={handleCaptureSpeechSelection} - onTouchEnd={handleCaptureSpeechSelection} sx={{ p: 1.5, borderRadius: 4, @@ -457,6 +553,43 @@ export const AgentTurn = React.memo( </Stack> </Box> + <Popper + open={isSpeechSelectionOpen} + anchorEl={speechSelectionAnchor} + placement="top" + transition + modifiers={[ + { name: "offset", options: { offset: [0, 8] } }, + { name: "flip", enabled: true }, + { name: "preventOverflow", options: { padding: 8 } }, + ]} + sx={{ zIndex: theme.zIndex.tooltip }} + > + {({ TransitionProps, placement }) => ( + <Grow + {...TransitionProps} + timeout={floatingActionTransitionTimeout} + style={{ + transformOrigin: placement.startsWith("bottom") + ? "center top" + : "center bottom", + }} + > + <Paper elevation={4} sx={floatingActionSurfaceSx}> + <Button + size="small" + startIcon={<VolumeUpRounded sx={{ fontSize: 16 }} />} + onMouseDown={(event) => event.preventDefault()} + onClick={handleSpeakFromSelection} + sx={floatingSpeechButtonSx} + > + 从这里开始朗读 + </Button> + </Paper> + </Grow> + )} + </Popper> + {visibleChartArtifacts.map((artifact) => ( <ChatInlineChart key={artifact.id} @@ -479,59 +612,49 @@ export const AgentTurn = React.memo( ))} </Stack> - <AnimatePresence> - {isHovered && !isStreaming && ( - <motion.div - initial={{ opacity: 0, scale: 0.9, y: 5 }} - animate={{ opacity: 1, scale: 1, y: 0 }} - exit={{ opacity: 0, scale: 0.9, y: 5 }} - transition={{ duration: 0.15 }} - style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }} - > - <Paper - elevation={4} - sx={{ - display: "flex", - gap: 0.5, - p: 0.5, - borderRadius: "16px", - bgcolor: alpha("#fff", 0.8), - backdropFilter: "blur(16px)", - border: `1px solid ${alpha("#fff", 0.9)}`, - boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`, + <Grow + in={isHovered && !isStreaming} + timeout={floatingActionTransitionTimeout} + mountOnEnter + unmountOnExit + style={{ transformOrigin: "right bottom" }} + > + <Paper + elevation={4} + sx={{ + ...floatingActionSurfaceSx, + position: "absolute", + top: -14, + right: 12, + zIndex: 10, + }} + > + <Tooltip title="复制"> + <IconButton + size="small" + aria-label="复制" + onClick={() => { + navigator.clipboard.writeText( + normalizeClipboardText(message.content), + ); }} + sx={floatingIconButtonSx} > - <Tooltip title="复制"> - <IconButton - size="small" - aria-label="复制" - onClick={() => { - navigator.clipboard.writeText( - normalizeClipboardText(message.content), - ); - // Could add a toast here - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - <ContentCopyRounded sx={{ fontSize: 16 }} /> - </IconButton> - </Tooltip> - <Tooltip title="拆分为新会话"> - <IconButton - size="small" - aria-label="拆分为新会话" - onClick={() => { - onCreateBranch(message.id); - }} - sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} - > - <TbArrowsSplit2 size={16} /> - </IconButton> - </Tooltip> - </Paper> - </motion.div> - )} - </AnimatePresence> + <ContentCopyRounded sx={{ fontSize: 16 }} /> + </IconButton> + </Tooltip> + <Tooltip title="拆分为新会话"> + <IconButton + size="small" + aria-label="拆分为新会话" + onClick={() => onCreateBranch(message.id)} + sx={floatingIconButtonSx} + > + <TbArrowsSplit2 size={16} /> + </IconButton> + </Tooltip> + </Paper> + </Grow> </Paper> </Stack> @@ -542,8 +665,8 @@ export const AgentTurn = React.memo( {messageSpeechState === "idle" ? ( <IconButton size="small" - onClick={handleSpeakFromCurrentStart} - aria-label={selectedSpeechStart ? "从选中位置朗读" : "朗读消息"} + onClick={handleSpeakMessage} + aria-label="朗读消息" sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} > <VolumeUpRounded sx={{ fontSize: 16 }} /> -- 2.54.0 From c6e6e24aab7ae8e265e82b9e9b1984914fbec8a4 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 10 Jul 2026 14:38:25 +0800 Subject: [PATCH 217/281] fix(chat): preserve agent UI across close --- src/components/chat/GlobalChatbox.test.tsx | 124 +++++++++++++++++++++ src/components/chat/GlobalChatbox.tsx | 48 ++++---- 2 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 src/components/chat/GlobalChatbox.test.tsx diff --git a/src/components/chat/GlobalChatbox.test.tsx b/src/components/chat/GlobalChatbox.test.tsx new file mode 100644 index 0000000..235eac9 --- /dev/null +++ b/src/components/chat/GlobalChatbox.test.tsx @@ -0,0 +1,124 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { act, render, screen } from "@testing-library/react"; + +import { GlobalChatbox } from "./GlobalChatbox"; + +const createSession = jest.fn(); +let mockCurrentProjectId = "project-1"; + +jest.mock("@refinedev/core", () => ({ + useNotification: () => ({ open: jest.fn() }), +})); + +jest.mock("@/lib/chatModels", () => ({ + fetchAgentModels: jest.fn(() => new Promise(() => {})), +})); + +jest.mock("@/store/projectStore", () => ({ + useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) => + selector({ currentProjectId: mockCurrentProjectId }), +})); + +jest.mock("./globalChatboxVoice", () => ({ + useSpeechSynthesis: () => ({ + speechState: "idle", + speakingMessageId: null, + speak: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + stop: jest.fn(), + isSupported: true, + }), + useSpeechRecognition: () => ({ + isListening: false, + start: jest.fn(), + stop: jest.fn(), + isSupported: true, + }), +})); + +jest.mock("./hooks/useAgentToolActions", () => ({ + useAgentToolActions: () => jest.fn(), +})); + +jest.mock("./hooks/useAgentChatSession", () => ({ + useAgentChatSession: () => ({ + messages: [], + chatSessions: [], + activeSessionId: undefined, + isHydrating: false, + loadingSessionId: null, + isStreaming: false, + sessionTitle: "新会话", + sendPrompt: jest.fn(), + createBranch: jest.fn(), + abort: jest.fn(), + replyPermission: jest.fn(), + replyQuestion: jest.fn(), + rejectQuestion: jest.fn(), + createSession, + renameSession: jest.fn(), + removeSession: jest.fn(), + switchSession: jest.fn(), + }), +})); + +jest.mock("./AgentHeader", () => ({ + AgentHeader: () => <div>Agent header</div>, +})); + +jest.mock("./AgentHistoryPanel", () => ({ + AgentHistoryPanel: () => <div>History</div>, +})); + +jest.mock("./AgentWorkspace", () => ({ + AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>, +})); + +jest.mock("./AgentComposer", () => ({ + AgentComposer: React.forwardRef(function MockAgentComposer() { + return <div>Composer</div>; + }), +})); + +jest.mock("./GlobalChatboxParts", () => ({ + Blob: () => null, +})); + +describe("GlobalChatbox lifecycle", () => { + beforeEach(() => { + jest.useFakeTimers(); + createSession.mockClear(); + mockCurrentProjectId = "project-1"; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it("keeps content mounted and preserves the session across close and reopen", async () => { + const { rerender } = render(<GlobalChatbox open onClose={jest.fn()} />); + + act(() => jest.runOnlyPendingTimers()); + expect(createSession).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("agent-workspace")).toBeInTheDocument(); + + rerender(<GlobalChatbox open={false} onClose={jest.fn()} />); + act(() => jest.advanceTimersByTime(300)); + + expect(screen.getByTestId("agent-workspace")).toBeInTheDocument(); + + rerender(<GlobalChatbox open onClose={jest.fn()} />); + act(() => jest.runOnlyPendingTimers()); + + expect(createSession).toHaveBeenCalledTimes(1); + + mockCurrentProjectId = "project-2"; + rerender(<GlobalChatbox open onClose={jest.fn()} />); + act(() => jest.runOnlyPendingTimers()); + + expect(createSession).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index eeaae1c..6c0e51f 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -42,7 +42,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { const isNearBottomRef = useRef(true); const streamingScrollFrameRef = useRef<number | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null); - const hasResetForOpenRef = useRef(false); + const initializedProjectIdRef = useRef<string | null | undefined>(undefined); const theme = useTheme(); const { open: openNotification } = useNotification(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -154,6 +154,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { isNearBottomRef.current = isNearBottom; }, []); + const resetConversationView = useCallback(() => { + composerRef.current?.clear(); + setIsHistoryOpen(false); + window.setTimeout(() => { + composerRef.current?.focus(); + isNearBottomRef.current = true; + cancelStreamingScroll(); + scrollToBottom("auto"); + }, 0); + }, [cancelStreamingScroll, scrollToBottom]); + useEffect(() => { if (isStreaming) { if (!isNearBottomRef.current) return; @@ -178,24 +189,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { ); useEffect(() => { - if (!open) { - hasResetForOpenRef.current = false; + if ( + !open || + isHydrating || + initializedProjectIdRef.current === currentProjectId + ) { return; } - if (hasResetForOpenRef.current || isHydrating) return; - hasResetForOpenRef.current = true; - const timer = window.setTimeout(() => { - createSession(); - composerRef.current?.clear(); - setIsHistoryOpen(false); - composerRef.current?.focus(); - isNearBottomRef.current = true; - cancelStreamingScroll(); - scrollToBottom("auto"); - }, 0); - return () => window.clearTimeout(timer); - }, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]); + initializedProjectIdRef.current = currentProjectId; + createSession(); + resetConversationView(); + }, [createSession, currentProjectId, isHydrating, open, resetConversationView]); const handleSend = useCallback(async (prompt: string) => { if (isStreaming || isCheckingAuth) return; @@ -230,14 +235,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { handleStopSpeech(); stopListening(); createSession(); - composerRef.current?.clear(); - window.setTimeout(() => { - composerRef.current?.focus(); - isNearBottomRef.current = true; - cancelStreamingScroll(); - scrollToBottom("auto"); - }, 0); - }, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]); + resetConversationView(); + }, [createSession, handleStopSpeech, resetConversationView, stopListening]); const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); @@ -311,6 +310,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { hideBackdrop disableScrollLock disableEnforceFocus + ModalProps={{ keepMounted: true }} sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, pointerEvents: "none", -- 2.54.0 From f5e7312e3b593b15e6f24ee27bcd80ceec5d807f Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 10 Jul 2026 15:31:14 +0800 Subject: [PATCH 218/281] perf(map): reuse resources across routes Preserve standard network layers between map pages while disposing route-owned overlays and controls to prevent memory growth. --- .../health-risk-analysis/loading.tsx | 0 .../(map)/health-risk-analysis/page.tsx | 38 ++ .../burst-detection/loading.tsx | 0 .../burst-detection/page.tsx | 17 + .../burst-location/loading.tsx | 0 .../burst-location/page.tsx | 17 + .../burst-simulation/loading.tsx | 0 .../burst-simulation/page.tsx | 17 + .../contaminant-simulation/loading.tsx | 0 .../contaminant-simulation/page.tsx | 17 + .../dma-leak-detection/loading.tsx | 0 .../dma-leak-detection/page.tsx | 17 + .../flushing-analysis/loading.tsx | 0 .../flushing-analysis/page.tsx | 13 + src/app/(main)/(map)/layout.tsx | 9 + .../monitoring-place-optimization/loading.tsx | 0 .../monitoring-place-optimization/page.tsx | 13 + .../network-simulation/loading.tsx | 0 .../{ => (map)}/network-simulation/page.tsx | 23 +- .../scada-data-cleaning/loading.tsx | 0 .../{ => (map)}/scada-data-cleaning/page.tsx | 31 +- src/app/(main)/health-risk-analysis/page.tsx | 43 -- .../burst-detection/page.tsx | 16 - .../burst-location/page.tsx | 20 - .../burst-simulation/page.tsx | 20 - .../contaminant-simulation/page.tsx | 20 - .../dma-leak-detection/page.tsx | 20 - .../flushing-analysis/page.tsx | 16 - .../monitoring-place-optimization/page.tsx | 15 - .../olmap/core/Controls/BaseLayers.tsx | 57 +- .../olmap/core/Controls/ScaleLine.tsx | 19 +- src/components/olmap/core/MapComponent.tsx | 553 +++--------------- .../olmap/core/mapLifecycle.test.ts | 94 +++ src/components/olmap/core/mapLifecycle.ts | 90 +++ .../olmap/core/operationalLayers.ts | 255 ++++++++ src/utils/layers.ts | 6 + 36 files changed, 753 insertions(+), 703 deletions(-) rename src/app/(main)/{ => (map)}/health-risk-analysis/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/health-risk-analysis/page.tsx rename src/app/(main)/{ => (map)}/hydraulic-simulation/burst-detection/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx rename src/app/(main)/{ => (map)}/hydraulic-simulation/burst-location/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx rename src/app/(main)/{ => (map)}/hydraulic-simulation/burst-simulation/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx rename src/app/(main)/{ => (map)}/hydraulic-simulation/contaminant-simulation/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx rename src/app/(main)/{ => (map)}/hydraulic-simulation/dma-leak-detection/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx rename src/app/(main)/{ => (map)}/hydraulic-simulation/flushing-analysis/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx create mode 100644 src/app/(main)/(map)/layout.tsx rename src/app/(main)/{ => (map)}/monitoring-place-optimization/loading.tsx (100%) create mode 100644 src/app/(main)/(map)/monitoring-place-optimization/page.tsx rename src/app/(main)/{ => (map)}/network-simulation/loading.tsx (100%) rename src/app/(main)/{ => (map)}/network-simulation/page.tsx (60%) rename src/app/(main)/{ => (map)}/scada-data-cleaning/loading.tsx (100%) rename src/app/(main)/{ => (map)}/scada-data-cleaning/page.tsx (54%) delete mode 100644 src/app/(main)/health-risk-analysis/page.tsx delete mode 100644 src/app/(main)/hydraulic-simulation/burst-detection/page.tsx delete mode 100644 src/app/(main)/hydraulic-simulation/burst-location/page.tsx delete mode 100644 src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx delete mode 100644 src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx delete mode 100644 src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx delete mode 100644 src/app/(main)/hydraulic-simulation/flushing-analysis/page.tsx delete mode 100644 src/app/(main)/monitoring-place-optimization/page.tsx create mode 100644 src/components/olmap/core/mapLifecycle.test.ts create mode 100644 src/components/olmap/core/mapLifecycle.ts create mode 100644 src/components/olmap/core/operationalLayers.ts diff --git a/src/app/(main)/health-risk-analysis/loading.tsx b/src/app/(main)/(map)/health-risk-analysis/loading.tsx similarity index 100% rename from src/app/(main)/health-risk-analysis/loading.tsx rename to src/app/(main)/(map)/health-risk-analysis/loading.tsx diff --git a/src/app/(main)/(map)/health-risk-analysis/page.tsx b/src/app/(main)/(map)/health-risk-analysis/page.tsx new file mode 100644 index 0000000..779e035 --- /dev/null +++ b/src/app/(main)/(map)/health-risk-analysis/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline"; +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext"; +import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"; +import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel"; +import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; +import { + RAINBOW_COLORS, + RISK_BREAKS, +} from "@components/olmap/HealthRiskAnalysis/types"; +import { Box } from "@mui/material"; + +export default function Home() { + return ( + <HealthRiskProvider> + <MapToolbar + queryType="realtime" + hiddenButtons={["style"]} + HistoryPanel={PredictDataPanel} + /> + <Timeline /> + <HealthRiskStatistics /> + <Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10"> + <StyleLegend + layerName="管道" + layerId="health-risk" + property="健康风险" + colors={RAINBOW_COLORS} + type="line" + dimensions={Array(RAINBOW_COLORS.length).fill(2)} + breaks={[0, ...RISK_BREAKS]} + /> + </Box> + </HealthRiskProvider> + ); +} diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-detection/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx rename to src/app/(main)/(map)/hydraulic-simulation/burst-detection/loading.tsx diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx new file mode 100644 index 0000000..ca5408c --- /dev/null +++ b/src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel"; + +export default function Home() { + return ( + <> + <MapToolbar + queryType="scheme" + schemeType="burst_detection" + hiddenButtons={["style"]} + /> + <BurstDetectionPanel /> + </> + ); +} diff --git a/src/app/(main)/hydraulic-simulation/burst-location/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-location/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/burst-location/loading.tsx rename to src/app/(main)/(map)/hydraulic-simulation/burst-location/loading.tsx diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx new file mode 100644 index 0000000..d5770b1 --- /dev/null +++ b/src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel"; + +export default function Home() { + return ( + <> + <MapToolbar + queryType="scheme" + schemeType="burst_location" + hiddenButtons={["style"]} + /> + <BurstLocationPanel /> + </> + ); +} diff --git a/src/app/(main)/hydraulic-simulation/burst-simulation/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/burst-simulation/loading.tsx rename to src/app/(main)/(map)/hydraulic-simulation/burst-simulation/loading.tsx diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx new file mode 100644 index 0000000..699997f --- /dev/null +++ b/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"; + +export default function Home() { + return ( + <> + <MapToolbar + queryType="scheme" + schemeType="burst_analysis" + enableCompare + /> + <BurstPipeAnalysisPanel /> + </> + ); +} diff --git a/src/app/(main)/hydraulic-simulation/contaminant-simulation/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/contaminant-simulation/loading.tsx rename to src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/loading.tsx diff --git a/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx new file mode 100644 index 0000000..6addcb6 --- /dev/null +++ b/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel"; + +export default function Home() { + return ( + <> + <MapToolbar + queryType="scheme" + schemeType="contaminant_analysis" + enableCompare + /> + <WaterQualityPanel /> + </> + ); +} diff --git a/src/app/(main)/hydraulic-simulation/dma-leak-detection/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/dma-leak-detection/loading.tsx rename to src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/loading.tsx diff --git a/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx new file mode 100644 index 0000000..39058bb --- /dev/null +++ b/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"; + +export default function Home() { + return ( + <> + <MapToolbar + queryType="scheme" + schemeType="dma_leak_identification" + hiddenButtons={["style"]} + /> + <DMALeakDetectionPanel /> + </> + ); +} diff --git a/src/app/(main)/hydraulic-simulation/flushing-analysis/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/loading.tsx similarity index 100% rename from src/app/(main)/hydraulic-simulation/flushing-analysis/loading.tsx rename to src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/loading.tsx diff --git a/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx new file mode 100644 index 0000000..19df843 --- /dev/null +++ b/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"; + +export default function Home() { + return ( + <> + <MapToolbar queryType="scheme" schemeType="flushing_analysis" /> + <FlushingAnalysisPanel /> + </> + ); +} diff --git a/src/app/(main)/(map)/layout.tsx b/src/app/(main)/(map)/layout.tsx new file mode 100644 index 0000000..18c0bae --- /dev/null +++ b/src/app/(main)/(map)/layout.tsx @@ -0,0 +1,9 @@ +"use client"; + +import type { ReactNode } from "react"; + +import MapComponent from "@components/olmap/core/MapComponent"; + +export default function MapLayout({ children }: { children: ReactNode }) { + return <MapComponent>{children}</MapComponent>; +} diff --git a/src/app/(main)/monitoring-place-optimization/loading.tsx b/src/app/(main)/(map)/monitoring-place-optimization/loading.tsx similarity index 100% rename from src/app/(main)/monitoring-place-optimization/loading.tsx rename to src/app/(main)/(map)/monitoring-place-optimization/loading.tsx diff --git a/src/app/(main)/(map)/monitoring-place-optimization/page.tsx b/src/app/(main)/(map)/monitoring-place-optimization/page.tsx new file mode 100644 index 0000000..0272841 --- /dev/null +++ b/src/app/(main)/(map)/monitoring-place-optimization/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import MapToolbar from "@components/olmap/core/Controls/Toolbar"; +import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"; + +export default function Home() { + return ( + <> + <MapToolbar hiddenButtons={["style"]} /> + <MonitoringPlaceOptimizationPanel /> + </> + ); +} diff --git a/src/app/(main)/network-simulation/loading.tsx b/src/app/(main)/(map)/network-simulation/loading.tsx similarity index 100% rename from src/app/(main)/network-simulation/loading.tsx rename to src/app/(main)/(map)/network-simulation/loading.tsx diff --git a/src/app/(main)/network-simulation/page.tsx b/src/app/(main)/(map)/network-simulation/page.tsx similarity index 60% rename from src/app/(main)/network-simulation/page.tsx rename to src/app/(main)/(map)/network-simulation/page.tsx index 18ec7c7..705afe1 100644 --- a/src/app/(main)/network-simulation/page.tsx +++ b/src/app/(main)/(map)/network-simulation/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useState } from "react"; -import MapComponent from "@components/olmap/core/MapComponent"; import Timeline from "@components/olmap/core/Controls/Timeline"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; @@ -22,17 +21,15 @@ export default function Home() { }, []); return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar queryType="realtime" /> - <Timeline /> - <SCADADeviceList - onDeviceClick={handleDeviceClick} - onSelectionChange={handleSelectionChange} - selectedDeviceIds={selectedDeviceIds} - /> - <SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} /> - </MapComponent> - </div> + <> + <MapToolbar queryType="realtime" /> + <Timeline /> + <SCADADeviceList + onDeviceClick={handleDeviceClick} + onSelectionChange={handleSelectionChange} + selectedDeviceIds={selectedDeviceIds} + /> + <SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} /> + </> ); } diff --git a/src/app/(main)/scada-data-cleaning/loading.tsx b/src/app/(main)/(map)/scada-data-cleaning/loading.tsx similarity index 100% rename from src/app/(main)/scada-data-cleaning/loading.tsx rename to src/app/(main)/(map)/scada-data-cleaning/loading.tsx diff --git a/src/app/(main)/scada-data-cleaning/page.tsx b/src/app/(main)/(map)/scada-data-cleaning/page.tsx similarity index 54% rename from src/app/(main)/scada-data-cleaning/page.tsx rename to src/app/(main)/(map)/scada-data-cleaning/page.tsx index 8458924..592a3b5 100644 --- a/src/app/(main)/scada-data-cleaning/page.tsx +++ b/src/app/(main)/(map)/scada-data-cleaning/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useState } from "react"; -import MapComponent from "@components/olmap/core/MapComponent"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList"; @@ -21,21 +20,19 @@ export default function Home() { }, []); return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar hiddenButtons={["style"]} /> - <SCADADeviceList - onDeviceClick={handleDeviceClick} - onSelectionChange={handleSelectionChange} - selectedDeviceIds={selectedDeviceIds} - showCleaning={true} - /> - <SCADADataPanel - deviceIds={selectedDeviceIds} - visible={panelVisible} - showCleaning={true} - /> - </MapComponent> - </div> + <> + <MapToolbar hiddenButtons={["style"]} /> + <SCADADeviceList + onDeviceClick={handleDeviceClick} + onSelectionChange={handleSelectionChange} + selectedDeviceIds={selectedDeviceIds} + showCleaning={true} + /> + <SCADADataPanel + deviceIds={selectedDeviceIds} + visible={panelVisible} + showCleaning={true} + /> + </> ); } diff --git a/src/app/(main)/health-risk-analysis/page.tsx b/src/app/(main)/health-risk-analysis/page.tsx deleted file mode 100644 index f6c014d..0000000 --- a/src/app/(main)/health-risk-analysis/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext"; -import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"; -import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel"; -import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; -import { - RAINBOW_COLORS, - RISK_BREAKS, -} from "@components/olmap/HealthRiskAnalysis/types"; -import { Box } from "@mui/material"; - -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <HealthRiskProvider> - <MapComponent> - <MapToolbar - queryType="realtime" - hiddenButtons={["style"]} - HistoryPanel={PredictDataPanel} - /> - <Timeline /> - <HealthRiskStatistics /> - <Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10"> - <StyleLegend - layerName="管道" - layerId="health-risk" - property="健康风险" - colors={RAINBOW_COLORS} - type="line" - dimensions={Array(RAINBOW_COLORS.length).fill(2)} - breaks={[0, ...RISK_BREAKS]} - /> - </Box> - </MapComponent> - </HealthRiskProvider> - </div> - ); -} diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx b/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx deleted file mode 100644 index df2ddb9..0000000 --- a/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel"; - -export default function Home() { - return ( - <div className="relative h-full w-full overflow-hidden"> - <MapComponent> - <MapToolbar queryType="scheme" schemeType="burst_detection" hiddenButtons={["style"]} /> - <BurstDetectionPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/app/(main)/hydraulic-simulation/burst-location/page.tsx b/src/app/(main)/hydraulic-simulation/burst-location/page.tsx deleted file mode 100644 index 977ef8e..0000000 --- a/src/app/(main)/hydraulic-simulation/burst-location/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel"; - -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar - queryType="scheme" - schemeType="burst_location" - hiddenButtons={["style"]} - /> - <BurstLocationPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx deleted file mode 100644 index 86f7864..0000000 --- a/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"; - -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar - queryType="scheme" - schemeType="burst_analysis" - enableCompare - /> - <BurstPipeAnalysisPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx deleted file mode 100644 index 265c631..0000000 --- a/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel"; - -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar - queryType="scheme" - schemeType="contaminant_analysis" - enableCompare - /> - <WaterQualityPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx b/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx deleted file mode 100644 index 68fc86a..0000000 --- a/src/app/(main)/hydraulic-simulation/dma-leak-detection/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"; - -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar - queryType="scheme" - schemeType="dma_leak_identification" - hiddenButtons={["style"]} - /> - <DMALeakDetectionPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/app/(main)/hydraulic-simulation/flushing-analysis/page.tsx b/src/app/(main)/hydraulic-simulation/flushing-analysis/page.tsx deleted file mode 100644 index 05e45cb..0000000 --- a/src/app/(main)/hydraulic-simulation/flushing-analysis/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"; - -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar queryType="scheme" schemeType="flushing_analysis" /> - <FlushingAnalysisPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/app/(main)/monitoring-place-optimization/page.tsx b/src/app/(main)/monitoring-place-optimization/page.tsx deleted file mode 100644 index 8ce7202..0000000 --- a/src/app/(main)/monitoring-place-optimization/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import MapComponent from "@components/olmap/core/MapComponent"; -import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"; -export default function Home() { - return ( - <div className="relative w-full h-full overflow-hidden"> - <MapComponent> - <MapToolbar hiddenButtons={["style"]} /> - <MonitoringPlaceOptimizationPanel /> - </MapComponent> - </div> - ); -} diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index aa43007..8685f67 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -14,8 +14,21 @@ import mapboxStreets from "@assets/map/layers/mapbox-streets.png"; import clsx from "clsx"; import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config"; import type { Map as OlMap } from "ol"; +import { markMapResourcePersistent } from "../mapLifecycle"; const INITIAL_LAYER = "mapbox-light"; +const BASE_LAYER_METADATA = [ + { id: "mapbox-light", name: "默认地图", img: mapboxLight.src }, + { id: "mapbox-satellite", name: "卫星地图", img: mapboxSatellite.src }, + { + id: "mapbox-satellite-streets", + name: "卫星街道地图", + img: mapboxSatelliteStreet.src, + }, + { id: "mapbox-streets", name: "街道地图", img: mapboxStreets.src }, + { id: "tianditu-vector", name: "天地图矢量", img: mapboxOutdoors.src }, + { id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src }, +] as const; const createTileLayer = (url: string, attributions: string) => new TileLayer({ @@ -77,46 +90,37 @@ const createBaseLayerEntries = () => { return [ { - id: "mapbox-light", - name: "默认地图", + ...BASE_LAYER_METADATA[0], layer: lightMapLayer, - img: mapboxLight.src, }, { - id: "mapbox-satellite", - name: "卫星地图", + ...BASE_LAYER_METADATA[1], layer: satelliteLayer, - img: mapboxSatellite.src, }, { - id: "mapbox-satellite-streets", - name: "卫星街道地图", + ...BASE_LAYER_METADATA[2], layer: satelliteStreetsLayer, - img: mapboxSatelliteStreet.src, }, { - id: "mapbox-streets", - name: "街道地图", + ...BASE_LAYER_METADATA[3], layer: streetsLayer, - img: mapboxStreets.src, }, { - id: "tianditu-vector", - name: "天地图矢量", + ...BASE_LAYER_METADATA[4], layer: new Group({ layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], }), - img: mapboxOutdoors.src, }, { - id: "tianditu-image", - name: "天地图影像", + ...BASE_LAYER_METADATA[5], layer: new Group({ layers: [tiandituImageLayer, tiandituImageAnnotationLayer], }), - img: mapboxSatellite.src, }, - ]; + ].map((entry) => ({ + ...entry, + layer: markMapResourcePersistent(entry.layer), + })); }; const BaseLayers: React.FC = () => { @@ -158,11 +162,7 @@ const BaseLayers: React.FC = () => { }); }; - const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({ - id, - name, - img, - })), []); + const baseLayers = BASE_LAYER_METADATA; const handleQuickSwitch = () => { const nextId = @@ -194,6 +194,15 @@ const BaseLayers: React.FC = () => { }, 300); }; + useEffect( + () => () => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + } + }, + [], + ); + return ( <div className="absolute right-17 bottom-11 z-20"> <div diff --git a/src/components/olmap/core/Controls/ScaleLine.tsx b/src/components/olmap/core/Controls/ScaleLine.tsx index b93d9ed..344e13a 100644 --- a/src/components/olmap/core/Controls/ScaleLine.tsx +++ b/src/components/olmap/core/Controls/ScaleLine.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useRef } from "react"; import { useMap } from "../MapComponent"; import { ScaleLine } from "ol/control"; +import { markMapResourcePersistent } from "../mapLifecycle"; const Scale: React.FC = () => { const map = useMap(); @@ -31,14 +32,16 @@ const Scale: React.FC = () => { updateZoomLevel(); // ScaleLine control - const scaleControl = new ScaleLine({ - target: scaleLineRef.current || undefined, - units: "metric", - bar: false, - steps: 4, - text: true, - minWidth: 64, - }); + const scaleControl = markMapResourcePersistent( + new ScaleLine({ + target: scaleLineRef.current || undefined, + units: "metric", + bar: false, + steps: 4, + text: true, + minWidth: 64, + }), + ); map.addControl(scaleControl); return () => { diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index b8eca89..2936f48 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -17,24 +17,21 @@ import MapTools from "./MapTools"; // 导入 DeckLayer import { DeckLayer } from "@utils/layers"; -import VectorTileSource from "ol/source/VectorTile"; -import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; -import MVT from "ol/format/MVT"; -import { FlatStyleLike } from "ol/style/flat"; import { toLonLat } from "ol/proj"; -import { along, bearing, lineString, length, toMercator } from "@turf/turf"; +import { along, bearing, lineString, length } from "@turf/turf"; import { Deck } from "@deck.gl/core"; import { TextLayer } from "@deck.gl/layers"; import { TripsLayer } from "@deck.gl/geo-layers"; import { CollisionFilterExtension } from "@deck.gl/extensions"; -import VectorSource from "ol/source/Vector"; -import GeoJson from "ol/format/GeoJSON"; -import VectorLayer from "ol/layer/Vector"; -import { Icon, Style } from "ol/style.js"; -import { FeatureLike } from "ol/Feature"; -import { Point } from "ol/geom"; import { ContourLayer } from "deck.gl"; import { toM3h } from "@utils/units"; +import { usePathname } from "next/navigation"; +import { + cleanupTransientMapResources, + disposeMapResources, + markMapResourcePersistent, +} from "./mapLifecycle"; +import { createOperationalMapResources } from "./operationalLayers"; interface MapComponentProps { children?: React.ReactNode; @@ -131,6 +128,7 @@ export const useData = () => { }; const MapComponent: React.FC<MapComponentProps> = ({ children }) => { + const pathname = usePathname(); const project = useProject(); const MAP_WORKSPACE = project?.workspace || config.MAP_WORKSPACE; const MAP_EXTENT = (project?.extent || config.MAP_EXTENT) as [ @@ -371,402 +369,20 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { debouncedUpdateDataRef.current = null; }; }, []); - // 配置地图数据源、图层和样式 - const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; - // 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标 - const scadaStyle = (feature: any) => { - const type = feature.get("type"); - const scadaPressureIcon = "/icons/scada_pressure.svg"; - const scadaFlowIcon = "/icons/scada_flow.svg"; - // 如果 type 不匹配,可以设置默认图标或不显示 - return new Style({ - image: new Icon({ - src: type === "pipe_flow" ? scadaFlowIcon : scadaPressureIcon, - scale: 0.1, // 根据需要调整图标大小 - anchor: [0.5, 0.5], // 图标锚点居中 + const operationalResources = useMemo( + () => + createOperationalMapResources({ + mapUrl: MAP_URL, + workspace: MAP_WORKSPACE, + extent: MAP_EXTENT, + persistent: true, }), - }); - }; - // 定义 reservoirs 图层的样式函数,使用固定图标 - const reservoirStyle = () => { - const reserviorIcon = "/icons/reservior.svg"; - return new Style({ - image: new Icon({ - src: reserviorIcon, - scale: 0.1, // 根据需要调整图标大小 - anchor: [0.5, 0.5], // 图标锚点居中 - }), - }); - }; - // 定义 tanks 图层的样式函数,使用固定图标 - const tankStyle = () => { - const tankIcon = "/icons/tank.svg"; - return new Style({ - image: new Icon({ - src: tankIcon, - scale: 0.1, // 根据需要调整图标大小 - anchor: [0.5, 0.5], // 图标锚点居中 - }), - }); - }; - const valveStyle = { - "icon-src": "/icons/valve.svg", - "icon-scale": 0.1, - }; - // 定义 pumps 图层的样式函数,使用固定图标 - const pumpStyle = function (feature: FeatureLike) { - const styles = []; - const pumpIcon = "/icons/pump.svg"; - - const geometry = feature.getGeometry(); - const lineCoords = - geometry?.getType() === "LineString" - ? (geometry as any).getCoordinates() - : null; - if (geometry) { - const lineCoordsWGS84 = lineCoords.map((coord: []) => { - const [lon, lat] = toLonLat(coord); - return [lon, lat]; - }); - // 计算中点 - const lineStringFeature = lineString(lineCoordsWGS84); - const lineLength = length(lineStringFeature); - const midPoint = along(lineStringFeature, lineLength / 2).geometry - .coordinates; - // 在中点添加 icon 样式 - const midPointMercator = toMercator(midPoint); - styles.push( - new Style({ - geometry: new Point(midPointMercator), - image: new Icon({ - src: pumpIcon, - scale: 0.12, - anchor: [0.5, 0.5], - }), - }), - ); - } - return styles; - }; - // 矢量瓦片数据源和图层 - const junctionSource = new VectorTileSource({ - url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL - format: new MVT(), - projection: "EPSG:3857", - }); - const pipeSource = new VectorTileSource({ - url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL - format: new MVT(), - projection: "EPSG:3857", - }); - const valveSource = new VectorTileSource({ - url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_valves@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL - format: new MVT(), - projection: "EPSG:3857", - }); - const reservoirSource = new VectorSource({ - url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_reservoirs&outputFormat=application/json`, - format: new GeoJson(), - }); - const pumpSource = new VectorSource({ - url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_pumps&outputFormat=application/json`, - format: new GeoJson(), - }); - const tankSource = new VectorSource({ - url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_tanks&outputFormat=application/json`, - format: new GeoJson(), - }); - const scadaSource = new VectorSource({ - url: `${MAP_URL}/${MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${MAP_WORKSPACE}:geo_scada&outputFormat=application/json`, - format: new GeoJson(), - }); - - // WebGL 渲染优化显示 - const junctionsLayer = new WebGLVectorTileLayer({ - source: junctionSource as any, // 使用 WebGL 渲染 - style: defaultFlatStyle, - extent: MAP_EXTENT, // 设置图层范围 - maxZoom: 24, - minZoom: 11, - properties: { - name: "节点", // 设置图层名称 - value: "junctions", - type: "point", - properties: [ - // { name: "需求量", value: "demand" }, - { name: "高程", value: "elevation" }, - // 计算属性 - { name: "实际需水量", value: "actual_demand" }, - { name: "水头", value: "total_head" }, - { name: "压力", value: "pressure" }, - { name: "水质", value: "quality" }, - ], - }, - }); - const pipesLayer = new WebGLVectorTileLayer({ - source: pipeSource as any, // 使用 WebGL 渲染 - style: defaultFlatStyle, - extent: MAP_EXTENT, // 设置图层范围 - maxZoom: 24, - minZoom: 11, - properties: { - name: "管道", // 设置图层名称 - value: "pipes", - type: "linestring", - properties: [ - { name: "管径", value: "diameter" }, - // { name: "粗糙度", value: "roughness" }, - // { name: "局部损失", value: "minor_loss" }, - // 计算属性 - { name: "流量", value: "flow" }, - { name: "摩阻系数", value: "friction" }, - { name: "水头损失", value: "headloss" }, - { name: "单位水头损失", value: "unit_headloss" }, - { name: "水质", value: "quality" }, - { name: "反应速率", value: "reaction" }, - { name: "设置值", value: "setting" }, - { name: "状态", value: "status" }, - { name: "流速", value: "velocity" }, - ], - }, - }); - const valvesLayer = new WebGLVectorTileLayer({ - source: valveSource as any, - style: valveStyle, - extent: MAP_EXTENT, // 设置图层范围 - maxZoom: 24, - minZoom: 16, - properties: { - name: "阀门", // 设置图层名称 - value: "valves", - type: "linestring", - properties: [], - }, - }); - const reservoirsLayer = new VectorLayer({ - source: reservoirSource, - style: reservoirStyle, - extent: MAP_EXTENT, // 设置图层范围 - maxZoom: 24, - minZoom: 11, - properties: { - name: "水库", // 设置图层名称 - value: "reservoirs", - type: "point", - properties: [], - }, - }); - const pumpsLayer = new VectorLayer({ - source: pumpSource, - style: pumpStyle, - extent: MAP_EXTENT, // 设置图层范围 - maxZoom: 24, - minZoom: 11, - properties: { - name: "水泵", // 设置图层名称 - value: "pumps", - type: "linestring", - properties: [], - }, - }); - const tanksLayer = new VectorLayer({ - source: tankSource, - style: tankStyle, - extent: MAP_EXTENT, // 设置图层范围 - maxZoom: 24, - minZoom: 11, - properties: { - name: "水箱", // 设置图层名称 - value: "tanks", - type: "point", - properties: [], - }, - }); - const scadaLayer = new VectorLayer({ - source: scadaSource, - style: scadaStyle, - extent: MAP_EXTENT, // 设置图层范围 - maxZoom: 24, - minZoom: 11, - properties: { - name: "SCADA", // 设置图层名称 - value: "scada", - type: "point", - properties: [], - }, - }); - - const createOperationalLayers = () => { - const nextJunctionSource = new VectorTileSource({ - url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, - format: new MVT(), - projection: "EPSG:3857", - }); - const nextPipeSource = new VectorTileSource({ - url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, - format: new MVT(), - projection: "EPSG:3857", - }); - const nextJunctionsLayer = new WebGLVectorTileLayer({ - source: nextJunctionSource as any, - style: defaultFlatStyle, - extent: MAP_EXTENT, - maxZoom: 24, - minZoom: 11, - properties: { - name: "节点", - value: "junctions", - type: "point", - properties: [ - { name: "高程", value: "elevation" }, - { name: "实际需水量", value: "actual_demand" }, - { name: "水头", value: "total_head" }, - { name: "压力", value: "pressure" }, - { name: "水质", value: "quality" }, - ], - }, - }); - const nextPipesLayer = new WebGLVectorTileLayer({ - source: nextPipeSource as any, - style: defaultFlatStyle, - extent: MAP_EXTENT, - maxZoom: 24, - minZoom: 11, - properties: { - name: "管道", - value: "pipes", - type: "linestring", - properties: [ - { name: "管径", value: "diameter" }, - { name: "流量", value: "flow" }, - { name: "摩阻系数", value: "friction" }, - { name: "水头损失", value: "headloss" }, - { name: "单位水头损失", value: "unit_headloss" }, - { name: "水质", value: "quality" }, - { name: "反应速率", value: "reaction" }, - { name: "设置值", value: "setting" }, - { name: "状态", value: "status" }, - { name: "流速", value: "velocity" }, - ], - }, - }); - const nextValvesLayer = new WebGLVectorTileLayer({ - source: valveSource as any, - style: valveStyle, - extent: MAP_EXTENT, - maxZoom: 24, - minZoom: 16, - properties: { - name: "阀门", - value: "valves", - type: "linestring", - properties: [], - }, - }); - const nextReservoirsLayer = new VectorLayer({ - source: reservoirSource, - style: reservoirStyle, - extent: MAP_EXTENT, - maxZoom: 24, - minZoom: 11, - properties: { - name: "水库", - value: "reservoirs", - type: "point", - properties: [], - }, - }); - const nextPumpsLayer = new VectorLayer({ - source: pumpSource, - style: pumpStyle, - extent: MAP_EXTENT, - maxZoom: 24, - minZoom: 11, - properties: { - name: "水泵", - value: "pumps", - type: "linestring", - properties: [], - }, - }); - const nextTanksLayer = new VectorLayer({ - source: tankSource, - style: tankStyle, - extent: MAP_EXTENT, - maxZoom: 24, - minZoom: 11, - properties: { - name: "水箱", - value: "tanks", - type: "point", - properties: [], - }, - }); - const nextScadaLayer = new VectorLayer({ - source: scadaSource, - style: scadaStyle, - extent: MAP_EXTENT, - maxZoom: 24, - minZoom: 11, - properties: { - name: "SCADA", - value: "scada", - type: "point", - properties: [], - }, - }); - - const availableLayers: any[] = []; - config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => { - switch (layerValue) { - case "junctions": - availableLayers.push(nextJunctionsLayer); - break; - case "pipes": - availableLayers.push(nextPipesLayer); - break; - case "valves": - availableLayers.push(nextValvesLayer); - break; - case "reservoirs": - availableLayers.push(nextReservoirsLayer); - break; - case "pumps": - availableLayers.push(nextPumpsLayer); - break; - case "tanks": - availableLayers.push(nextTanksLayer); - break; - case "scada": - availableLayers.push(nextScadaLayer); - break; - } - }); - availableLayers.sort((a, b) => { - const order = [ - "valves", - "junctions", - "scada", - "reservoirs", - "pumps", - "tanks", - "pipes", - ].reverse(); - const getValue = (layer: any) => { - const props = layer.get ? layer.get("properties") : undefined; - return (props && props.value) || layer.get?.("value") || ""; - }; - const aVal = getValue(a); - const bVal = getValue(b); - let ia = order.indexOf(aVal); - let ib = order.indexOf(bVal); - if (ia === -1) ia = order.length; - if (ib === -1) ib = order.length; - return ia - ib; - }); - - return availableLayers; - }; + [MAP_URL, MAP_WORKSPACE, MAP_EXTENT], + ); + const { junctions: junctionSource, pipes: pipeSource } = + operationalResources.sources; + const { junctions: junctionsLayer, pipes: pipesLayer } = + operationalResources.layers; // The map and layer instances are intentionally rebuilt only when workspace or extent changes. useEffect(() => { @@ -775,6 +391,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { return; } isDisposingRef.current = false; + const activeJunctionDataIds = junctionDataIds.current; + const activePipeDataIds = pipeDataIds.current; const addTimeout = (callback: () => void, delay: number) => { const timerId = window.setTimeout(() => { @@ -927,58 +545,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 添加事件监听器 junctionsLayer.on("change:visible", handleJunctionVisibilityChange); pipesLayer.on("change:visible", handlePipeVisibilityChange); - const availableLayers: any[] = []; - config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => { - switch (layerValue) { - case "junctions": - availableLayers.push(junctionsLayer); - break; - case "pipes": - availableLayers.push(pipesLayer); - break; - case "valves": - availableLayers.push(valvesLayer); - break; - case "reservoirs": - availableLayers.push(reservoirsLayer); - break; - case "pumps": - availableLayers.push(pumpsLayer); - break; - case "tanks": - availableLayers.push(tanksLayer); - break; - case "scada": - availableLayers.push(scadaLayer); - break; - } - }); - // 重新排列图层顺序,确保顺序 点>线>面 - availableLayers.sort((a, b) => { - // 明确顺序(点类优先),这里 valves 特殊处理 - const order = [ - "valves", - "junctions", - "scada", - "reservoirs", - "pumps", - "tanks", - "pipes", - ].reverse(); - // 取值时做安全检查,兼容不同写法(properties.value 或 直接 value) - const getValue = (layer: any) => { - const props = layer.get ? layer.get("properties") : undefined; - return (props && props.value) || layer.get?.("value") || ""; - }; - const aVal = getValue(a); - const bVal = getValue(b); - let ia = order.indexOf(aVal); - let ib = order.indexOf(bVal); - // 如果未在 order 中找到,放到末尾 - if (ia === -1) ia = order.length; - if (ib === -1) ib = order.length; - return ia - ib; - }); const map = new OlMap({ target: mapRef.current, view: new View({ @@ -986,9 +552,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { projection: "EPSG:3857", }), // 图层依面、线、点、标注次序添加 - layers: availableLayers.slice(), + layers: operationalResources.orderedLayers.slice(), controls: [], }); + map.getInteractions().forEach(markMapResourcePersistent); setMap(map); // 恢复上次视图;如果没有则适配 MAP_EXTENT @@ -1072,10 +639,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { controller: false, // 由 OpenLayers 控制视图 layers: [], }); - const deckLayer = new DeckLayer(deck, canvasRef.current, { - name: "deckLayer", - value: "deckLayer", - }); + const deckLayer = markMapResourcePersistent( + new DeckLayer(deck, canvasRef.current, { + name: "deckLayer", + value: "deckLayer", + }), + ); deckLayerRef.current = deckLayer; setDeckLayer(deckLayer); map.addLayer(deckLayer); @@ -1101,8 +670,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { } deckLayerRef.current = null; setDeckLayer(undefined); - map.setTarget(undefined); - map.dispose(); + // React Strict Mode re-runs effects with the same memoized layer instances. + // Detach and clear them here, but leave final layer/source disposal to GC. + disposeMapResources(map, { disposeLayers: false }); + activeJunctionDataIds.clear(); + activePipeDataIds.clear(); + tileJunctionDataBuffer.current = []; + tilePipeDataBuffer.current = []; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [MAP_WORKSPACE, MAP_EXTENT]); @@ -1117,11 +691,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { if (!map || !compareMapRef.current || !compareCanvasRef.current) return; isCompareDisposingRef.current = false; - const availableLayers = createOperationalLayers(); + const compareResources = createOperationalMapResources({ + mapUrl: MAP_URL, + workspace: MAP_WORKSPACE, + extent: MAP_EXTENT, + }); const nextCompareMap = new OlMap({ target: compareMapRef.current, view: map.getView(), - layers: availableLayers.slice(), + layers: compareResources.orderedLayers.slice(), controls: [], }); nextCompareMap.getAllLayers().forEach((layer) => { @@ -1180,8 +758,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { compareDeckLayerRef.current = null; setCompareDeckLayer(undefined); setCompareMap(undefined); - nextCompareMap.setTarget(undefined); - nextCompareMap.dispose(); + disposeMapResources(nextCompareMap); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isCompareMode, map]); @@ -1197,6 +774,34 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }; }, [compareMap, isCompareMode, map]); + useEffect(() => { + setCurrentTime(-1); + setSelectedDate(new Date()); + setSchemeName(""); + setCurrentJunctionCalData([]); + setCurrentPipeCalData([]); + setCompareJunctionCalData([]); + setComparePipeCalData([]); + setCompareMode(false); + setShowJunctionTextLayer(false); + setShowPipeTextLayer(false); + setShowJunctionId(false); + setShowPipeId(false); + setShowContourLayer(false); + setContours([]); + setContourLayerAvailable(false); + setWaterflowLayerAvailable(false); + setShowWaterflowLayer(false); + setForceStyleAutoApplyVersion(0); + + return () => { + if (!map) return; + cleanupTransientMapResources(map); + deckLayerRef.current?.resetSessionLayers(); + operationalResources.resetStyles(); + }; + }, [pathname, map, operationalResources]); + // 当数据变化时,更新 deck.gl 图层 useEffect(() => { const syncDeckOverlay = ( @@ -1348,7 +953,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }) : null; - if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) { + if ( + junctionTextLayer && + targetDeckLayer.getDeckLayerById("junctionTextLayer") + ) { targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer); } else if (junctionTextLayer) { targetDeckLayer.addDeckLayer(junctionTextLayer); @@ -1358,7 +966,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { } else if (pipeTextLayer) { targetDeckLayer.addDeckLayer(pipeTextLayer); } - if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) { + if ( + contourLayer && + targetDeckLayer.getDeckLayerById("junctionContourLayer") + ) { targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer); } else if (contourLayer) { targetDeckLayer.addDeckLayer(contourLayer); diff --git a/src/components/olmap/core/mapLifecycle.test.ts b/src/components/olmap/core/mapLifecycle.test.ts new file mode 100644 index 0000000..38c4056 --- /dev/null +++ b/src/components/olmap/core/mapLifecycle.test.ts @@ -0,0 +1,94 @@ +import { + cleanupTransientMapResources, + disposeMapResources, + isMapResourcePersistent, + markMapResourcePersistent, +} from "./mapLifecycle"; + +const createCollection = <T,>(items: T[]) => ({ + getArray: () => items, + clear: () => items.splice(0, items.length), +}); + +const createResource = () => { + const properties = new Map<string, unknown>(); + return { + get: (key: string) => properties.get(key), + set: (key: string, value: unknown) => properties.set(key, value), + dispose: jest.fn(), + }; +}; + +describe("map lifecycle", () => { + it("preserves shared resources and releases session resources", () => { + const persistentLayer = markMapResourcePersistent(createResource()); + const transientSource = { clear: jest.fn(), dispose: jest.fn() }; + const transientLayer = { + ...createResource(), + getSource: () => transientSource, + }; + const persistentInteraction = markMapResourcePersistent(createResource()); + const transientInteraction = createResource(); + const persistentControl = markMapResourcePersistent(createResource()); + const transientControl = createResource(); + + const layers = [persistentLayer, transientLayer]; + const interactions = [persistentInteraction, transientInteraction]; + const controls = [persistentControl, transientControl]; + const overlays: ReturnType<typeof createResource>[] = []; + const map = { + getLayers: () => createCollection(layers), + removeLayer: (resource: unknown) => layers.splice(layers.indexOf(resource as never), 1), + getInteractions: () => createCollection(interactions), + removeInteraction: (resource: unknown) => + interactions.splice(interactions.indexOf(resource as never), 1), + getControls: () => createCollection(controls), + removeControl: (resource: unknown) => controls.splice(controls.indexOf(resource as never), 1), + getOverlays: () => createCollection(overlays), + removeOverlay: (resource: unknown) => overlays.splice(overlays.indexOf(resource as never), 1), + } as any; + + cleanupTransientMapResources(map); + + expect(layers).toEqual([persistentLayer]); + expect(interactions).toEqual([persistentInteraction]); + expect(controls).toEqual([persistentControl]); + expect(transientSource.clear).toHaveBeenCalledTimes(1); + expect(transientSource.dispose).toHaveBeenCalledTimes(1); + expect(transientLayer.dispose).toHaveBeenCalledTimes(1); + expect(transientInteraction.dispose).toHaveBeenCalledTimes(1); + expect(transientControl.dispose).toHaveBeenCalledTimes(1); + }); + + it("marks only explicitly shared resources as persistent", () => { + const resource = createResource(); + expect(isMapResourcePersistent(resource)).toBe(false); + markMapResourcePersistent(resource); + expect(isMapResourcePersistent(resource)).toBe(true); + }); + + it("keeps memoized layers reusable across Strict Mode effect cleanup", () => { + const source = { clear: jest.fn(), dispose: jest.fn() }; + const layer = { ...createResource(), getSource: () => source }; + const layers = [layer]; + const interactions: ReturnType<typeof createResource>[] = []; + const controls: ReturnType<typeof createResource>[] = []; + const overlays: ReturnType<typeof createResource>[] = []; + const map = { + getLayers: () => createCollection(layers), + removeLayer: (resource: unknown) => layers.splice(layers.indexOf(resource as never), 1), + getInteractions: () => createCollection(interactions), + getControls: () => createCollection(controls), + getOverlays: () => createCollection(overlays), + setTarget: jest.fn(), + dispose: jest.fn(), + } as any; + + disposeMapResources(map, { disposeLayers: false }); + + expect(source.clear).toHaveBeenCalledTimes(1); + expect(source.dispose).not.toHaveBeenCalled(); + expect(layer.dispose).not.toHaveBeenCalled(); + expect(map.dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/olmap/core/mapLifecycle.ts b/src/components/olmap/core/mapLifecycle.ts new file mode 100644 index 0000000..8e6404c --- /dev/null +++ b/src/components/olmap/core/mapLifecycle.ts @@ -0,0 +1,90 @@ +import type { Map as OlMap } from "ol"; + +const RESOURCE_SCOPE_KEY = "tjwater_resource_scope"; +const PERSISTENT_SCOPE = "persistent"; + +type MapResource = { + get?: (key: string) => unknown; + set?: (key: string, value: unknown, silent?: boolean) => void; + dispose?: () => void; +}; + +export const markMapResourcePersistent = <T extends MapResource>(resource: T): T => { + resource.set?.(RESOURCE_SCOPE_KEY, PERSISTENT_SCOPE, true); + return resource; +}; + +export const isMapResourcePersistent = (resource: MapResource) => + resource.get?.(RESOURCE_SCOPE_KEY) === PERSISTENT_SCOPE; + +const removeTransientResources = <T extends MapResource>( + resources: T[], + remove: (resource: T) => void, +) => { + [...resources].forEach((resource) => { + if (isMapResourcePersistent(resource)) return; + remove(resource); + resource.dispose?.(); + }); +}; + +const releaseLayer = (layer: any, dispose: boolean) => { + const childLayers = layer.getLayers?.().getArray?.(); + if (Array.isArray(childLayers)) { + [...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose)); + layer.getLayers().clear(); + } + + const source = layer.getSource?.(); + try { + source?.clear?.(); + } catch { + // Some third-party sources do not support explicit cache clearing. + } + if (dispose) { + try { + source?.dispose?.(); + } catch { + // Source may already be disposed by its owning layer. + } + try { + layer.dispose?.(); + } catch { + // Cleanup is deliberately idempotent for overlapping route unmounts. + } + } +}; + +export const cleanupTransientMapResources = (map: OlMap) => { + [...map.getLayers().getArray()].forEach((layer) => { + if (isMapResourcePersistent(layer)) return; + map.removeLayer(layer); + releaseLayer(layer, true); + }); + + removeTransientResources(map.getInteractions().getArray(), (interaction) => + map.removeInteraction(interaction), + ); + removeTransientResources(map.getControls().getArray(), (control) => + map.removeControl(control), + ); + removeTransientResources(map.getOverlays().getArray(), (overlay) => + map.removeOverlay(overlay), + ); +}; + +export const disposeMapResources = ( + map: OlMap, + options: { disposeLayers?: boolean } = {}, +) => { + const disposeLayers = options.disposeLayers ?? true; + [...map.getLayers().getArray()].forEach((layer) => { + map.removeLayer(layer); + releaseLayer(layer, disposeLayers); + }); + map.getInteractions().clear(); + map.getControls().clear(); + map.getOverlays().clear(); + map.setTarget(undefined); + map.dispose(); +}; diff --git a/src/components/olmap/core/operationalLayers.ts b/src/components/olmap/core/operationalLayers.ts new file mode 100644 index 0000000..7f5ecba --- /dev/null +++ b/src/components/olmap/core/operationalLayers.ts @@ -0,0 +1,255 @@ +import { config } from "@/config/config"; +import { along, lineString, length, toMercator } from "@turf/turf"; +import type { FeatureLike } from "ol/Feature"; +import MVT from "ol/format/MVT"; +import { Point } from "ol/geom"; +import type BaseLayer from "ol/layer/Base"; +import VectorLayer from "ol/layer/Vector"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import { toLonLat } from "ol/proj"; +import GeoJSON from "ol/format/GeoJSON"; +import VectorSource from "ol/source/Vector"; +import VectorTileSource from "ol/source/VectorTile"; +import { Icon, Style } from "ol/style"; +import type { FlatStyleLike } from "ol/style/flat"; + +import { markMapResourcePersistent } from "./mapLifecycle"; + +type MapExtent = [number, number, number, number]; + +type CreateOperationalLayersOptions = { + mapUrl: string; + workspace: string; + extent: MapExtent; + persistent?: boolean; +}; + +const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike; +const valveStyle = { + "icon-src": "/icons/valve.svg", + "icon-scale": 0.1, +}; + +const createIconStyle = (src: string, scale = 0.1) => + new Style({ image: new Icon({ src, scale, anchor: [0.5, 0.5] }) }); + +const scadaStyle = (feature: FeatureLike) => + createIconStyle( + feature.get("type") === "pipe_flow" + ? "/icons/scada_flow.svg" + : "/icons/scada_pressure.svg", + ); + +const pumpStyle = (feature: FeatureLike) => { + const geometry = feature.getGeometry(); + if (!geometry || geometry.getType() !== "LineString") return []; + + const coordinates = (geometry as any) + .getCoordinates() + .map((coordinate: number[]) => toLonLat(coordinate)); + if (coordinates.length < 2) return []; + + const featureLine = lineString(coordinates); + const midpoint = along(featureLine, length(featureLine) / 2).geometry + .coordinates; + return [ + new Style({ + geometry: new Point(toMercator(midpoint)), + image: new Icon({ + src: "/icons/pump.svg", + scale: 0.12, + anchor: [0.5, 0.5], + }), + }), + ]; +}; + +const pointProperties = [ + { name: "高程", value: "elevation" }, + { name: "实际需水量", value: "actual_demand" }, + { name: "水头", value: "total_head" }, + { name: "压力", value: "pressure" }, + { name: "水质", value: "quality" }, +]; + +const pipeProperties = [ + { name: "管径", value: "diameter" }, + { name: "流量", value: "flow" }, + { name: "摩阻系数", value: "friction" }, + { name: "水头损失", value: "headloss" }, + { name: "单位水头损失", value: "unit_headloss" }, + { name: "水质", value: "quality" }, + { name: "反应速率", value: "reaction" }, + { name: "设置值", value: "setting" }, + { name: "状态", value: "status" }, + { name: "流速", value: "velocity" }, +]; + +export const createOperationalMapResources = ({ + mapUrl, + workspace, + extent, + persistent = false, +}: CreateOperationalLayersOptions) => { + const vectorTileUrl = (name: string) => + `${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`; + const vectorUrl = (name: string) => + `${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`; + + const sources = { + junctions: new VectorTileSource({ + url: vectorTileUrl("geo_junctions"), + format: new MVT(), + projection: "EPSG:3857", + }), + pipes: new VectorTileSource({ + url: vectorTileUrl("geo_pipes"), + format: new MVT(), + projection: "EPSG:3857", + }), + valves: new VectorTileSource({ + url: vectorTileUrl("geo_valves"), + format: new MVT(), + projection: "EPSG:3857", + }), + reservoirs: new VectorSource({ + url: vectorUrl("geo_reservoirs"), + format: new GeoJSON(), + }), + pumps: new VectorSource({ + url: vectorUrl("geo_pumps"), + format: new GeoJSON(), + }), + tanks: new VectorSource({ + url: vectorUrl("geo_tanks"), + format: new GeoJSON(), + }), + scada: new VectorSource({ + url: vectorUrl("geo_scada"), + format: new GeoJSON(), + }), + }; + + const layers = { + junctions: new WebGLVectorTileLayer({ + source: sources.junctions as any, + style: defaultFlatStyle, + extent, + maxZoom: 24, + minZoom: 11, + properties: { + name: "节点", + value: "junctions", + type: "point", + properties: pointProperties, + }, + }), + pipes: new WebGLVectorTileLayer({ + source: sources.pipes as any, + style: defaultFlatStyle, + extent, + maxZoom: 24, + minZoom: 11, + properties: { + name: "管道", + value: "pipes", + type: "linestring", + properties: pipeProperties, + }, + }), + valves: new WebGLVectorTileLayer({ + source: sources.valves as any, + style: valveStyle, + extent, + maxZoom: 24, + minZoom: 16, + properties: { + name: "阀门", + value: "valves", + type: "linestring", + properties: [], + }, + }), + reservoirs: new VectorLayer({ + source: sources.reservoirs, + style: () => createIconStyle("/icons/reservior.svg"), + extent, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水库", + value: "reservoirs", + type: "point", + properties: [], + }, + }), + pumps: new VectorLayer({ + source: sources.pumps, + style: pumpStyle, + extent, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水泵", + value: "pumps", + type: "linestring", + properties: [], + }, + }), + tanks: new VectorLayer({ + source: sources.tanks, + style: () => createIconStyle("/icons/tank.svg"), + extent, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水箱", + value: "tanks", + type: "point", + properties: [], + }, + }), + scada: new VectorLayer({ + source: sources.scada, + style: scadaStyle, + extent, + maxZoom: 24, + minZoom: 11, + properties: { + name: "SCADA", + value: "scada", + type: "point", + properties: [], + }, + }), + }; + + if (persistent) { + Object.values(layers).forEach(markMapResourcePersistent); + } + + const layerById = layers as Record<string, BaseLayer>; + const enabledLayers = new Set(config.MAP_AVAILABLE_LAYERS); + const orderedLayers = [ + "pipes", + "tanks", + "pumps", + "reservoirs", + "scada", + "junctions", + "valves", + ] + .filter((id) => enabledLayers.has(id)) + .map((id) => layerById[id]); + + return { + sources, + layers, + orderedLayers, + resetStyles: () => { + layers.junctions.setStyle(defaultFlatStyle); + layers.pipes.setStyle(defaultFlatStyle); + layers.valves.setStyle(valveStyle); + }, + }; +}; diff --git a/src/utils/layers.ts b/src/utils/layers.ts index c9ecc30..32ed54a 100644 --- a/src/utils/layers.ts +++ b/src/utils/layers.ts @@ -70,6 +70,12 @@ export class DeckLayer extends Layer { this.deck.setProps({ layers }); } + resetSessionLayers(): void { + if (this.isDisposed) return; + this.userVisibility.clear(); + this.deck.setProps({ layers: [] }); + } + // 获取当前图层 getDeckLayers(): any[] { if (this.isDisposed) return []; -- 2.54.0 From 041b4ef89d02ed380da9aa1934771b60464b1b68 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 11:31:51 +0800 Subject: [PATCH 219/281] fix(timeline): prevent negative initial time --- .../olmap/core/Controls/Timeline.tsx | 47 ++++++++++--------- .../olmap/core/Controls/timelineTime.test.ts | 21 +++++++++ .../olmap/core/Controls/timelineTime.ts | 32 +++++++++++++ src/components/olmap/core/MapComponent.tsx | 7 ++- 4 files changed, 83 insertions(+), 24 deletions(-) create mode 100644 src/components/olmap/core/Controls/timelineTime.test.ts create mode 100644 src/components/olmap/core/Controls/timelineTime.ts diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index 31ad733..e5a290d 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -30,6 +30,11 @@ import { useData } from "../MapComponent"; import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; import { useMap } from "../MapComponent"; +import { + formatTimelineTime, + getRoundedCurrentTimelineMinutes, + normalizeTimelineMinutes, +} from "./timelineTime"; interface TimelineProps { schemeDate?: Date; @@ -99,6 +104,11 @@ const Timeline: React.FC<TimelineProps> = ({ const maxTime = timeRange ? timeRange.end.getHours() * 60 + timeRange.end.getMinutes() : 1440; + const timelineCurrentTime = normalizeTimelineMinutes( + currentTime, + minTime, + maxTime, + ); useEffect(() => { if (schemeDate) { setSelectedDate(schemeDate); @@ -327,20 +337,17 @@ const Timeline: React.FC<TimelineProps> = ({ // 格式化时间显示 function formatTime(minutes: number): string { - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return `${hours.toString().padStart(2, "0")}:${mins - .toString() - .padStart(2, "0")}`; + return formatTimelineTime(minutes, minTime, maxTime); } - function currentTimeToDate(selectedDate: Date, minutes: number): Date { + const currentTimeToDate = useCallback((selectedDate: Date, minutes: number): Date => { const date = new Date(selectedDate); - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; + const normalizedMinutes = normalizeTimelineMinutes(minutes, minTime, maxTime); + const hours = Math.floor(normalizedMinutes / 60); + const mins = normalizedMinutes % 60; date.setHours(hours, mins, 0, 0); return date; - } + }, [maxTime, minTime]); // 播放时间间隔选项 const intervalOptions = [ @@ -414,9 +421,7 @@ const Timeline: React.FC<TimelineProps> = ({ const handleStop = useCallback(() => { setIsPlaying(false); // 设置为当前时间 - const currentTime = new Date(); - const minutes = currentTime.getHours() * 60 + currentTime.getMinutes(); - setCurrentTime(minutes); // 组件卸载时重置时间 + setCurrentTime(getRoundedCurrentTimelineMinutes()); // 重置为当前时间 if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; @@ -514,7 +519,7 @@ const Timeline: React.FC<TimelineProps> = ({ // return; // } fetchFrameData( - currentTimeToDate(selectedDate, currentTime), + currentTimeToDate(selectedDate, timelineCurrentTime), junctionText, pipeText, schemeName, @@ -526,6 +531,8 @@ const Timeline: React.FC<TimelineProps> = ({ junctionText, pipeText, currentTime, + currentTimeToDate, + timelineCurrentTime, selectedDate, schemeName, schemeType, @@ -534,11 +541,7 @@ const Timeline: React.FC<TimelineProps> = ({ // 组件卸载时清理定时器和防抖 useEffect(() => { // 设置为当前时间 - const currentTime = new Date(); - const minutes = currentTime.getHours() * 60 + currentTime.getMinutes(); - // 找到最近的前15分钟刻度 - const roundedMinutes = Math.floor(minutes / 15) * 15; - setCurrentTime(roundedMinutes); // 组件卸载时重置时间 + setCurrentTime(getRoundedCurrentTimelineMinutes()); // 初始化为当前时间 return () => { if (intervalRef.current) { @@ -603,7 +606,7 @@ const Timeline: React.FC<TimelineProps> = ({ clearCache(linkCacheRef); // 重新获取当前时刻的新数据 fetchFrameData( - currentTimeToDate(selectedDate, currentTime), + currentTimeToDate(selectedDate, timelineCurrentTime), junctionText, pipeText, schemeName, @@ -622,7 +625,7 @@ const Timeline: React.FC<TimelineProps> = ({ // 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变 const calculationDate = selectedDate; - const calculationTime = currentTime; + const calculationTime = timelineCurrentTime; const calculationDateTime = currentTimeToDate( calculationDate, calculationTime @@ -890,13 +893,13 @@ const Timeline: React.FC<TimelineProps> = ({ color: "primary.main", }} > - {formatTime(currentTime)} + {formatTime(timelineCurrentTime)} </Typography> </Stack> <Box ref={timelineRef} sx={{ px: 2, position: "relative" }}> <Slider - value={currentTime} + value={timelineCurrentTime} min={0} max={1440} // 24:00 = 1440分钟 step={15} // 每15分钟一个步进 diff --git a/src/components/olmap/core/Controls/timelineTime.test.ts b/src/components/olmap/core/Controls/timelineTime.test.ts new file mode 100644 index 0000000..2928845 --- /dev/null +++ b/src/components/olmap/core/Controls/timelineTime.test.ts @@ -0,0 +1,21 @@ +import { + formatTimelineTime, + getRoundedCurrentTimelineMinutes, + normalizeTimelineMinutes, +} from "./timelineTime"; + +describe("timelineTime", () => { + it("normalizes invalid minutes before formatting", () => { + expect(formatTimelineTime(-1)).toBe("00:00"); + expect(formatTimelineTime(Number.NaN)).toBe("00:00"); + }); + + it("clamps minutes to the configured range", () => { + expect(normalizeTimelineMinutes(-1, 60, 120)).toBe(60); + expect(normalizeTimelineMinutes(180, 60, 120)).toBe(120); + }); + + it("rounds the current time down to the timeline step", () => { + expect(getRoundedCurrentTimelineMinutes(new Date("2026-07-16T10:29:00"))).toBe(615); + }); +}); diff --git a/src/components/olmap/core/Controls/timelineTime.ts b/src/components/olmap/core/Controls/timelineTime.ts new file mode 100644 index 0000000..42f17b0 --- /dev/null +++ b/src/components/olmap/core/Controls/timelineTime.ts @@ -0,0 +1,32 @@ +export const TIMELINE_MINUTES_PER_DAY = 1440; +export const TIMELINE_STEP_MINUTES = 15; + +export const normalizeTimelineMinutes = ( + minutes: number | undefined, + minTime = 0, + maxTime = TIMELINE_MINUTES_PER_DAY, +) => { + if (typeof minutes !== "number" || !Number.isFinite(minutes)) { + return minTime; + } + + return Math.min(Math.max(minutes, minTime), maxTime); +}; + +export const getRoundedCurrentTimelineMinutes = (date = new Date()) => { + const minutes = date.getHours() * 60 + date.getMinutes(); + return Math.floor(minutes / TIMELINE_STEP_MINUTES) * TIMELINE_STEP_MINUTES; +}; + +export const formatTimelineTime = ( + minutes: number | undefined, + minTime = 0, + maxTime = TIMELINE_MINUTES_PER_DAY, +) => { + const normalizedMinutes = normalizeTimelineMinutes(minutes, minTime, maxTime); + const hours = Math.floor(normalizedMinutes / 60); + const mins = normalizedMinutes % 60; + return `${hours.toString().padStart(2, "0")}:${mins + .toString() + .padStart(2, "0")}`; +}; diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 2936f48..b757a37 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -32,6 +32,7 @@ import { markMapResourcePersistent, } from "./mapLifecycle"; import { createOperationalMapResources } from "./operationalLayers"; +import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime"; interface MapComponentProps { children?: React.ReactNode; @@ -155,7 +156,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const [compareMap, setCompareMap] = useState<OlMap>(); const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>(); // currentCalData 用于存储当前计算结果 - const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间 + const [currentTime, setCurrentTime] = useState<number>( + getRoundedCurrentTimelineMinutes, + ); // 默认选择当前时间 // const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17")); const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天 const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称 @@ -775,7 +778,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }, [compareMap, isCompareMode, map]); useEffect(() => { - setCurrentTime(-1); + setCurrentTime(getRoundedCurrentTimelineMinutes()); setSelectedDate(new Date()); setSchemeName(""); setCurrentJunctionCalData([]); -- 2.54.0 From d90ca7c9517a1755f6ee4b4d3fc4a36ecc99e845 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 11:38:22 +0800 Subject: [PATCH 220/281] fix(timeline): align range mask with slider --- .../olmap/core/Controls/Timeline.tsx | 38 +++++++++++-------- .../olmap/core/Controls/timelineTime.test.ts | 9 +++++ .../olmap/core/Controls/timelineTime.ts | 15 ++++++++ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index e5a290d..02d15fb 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -33,6 +33,7 @@ import { useMap } from "../MapComponent"; import { formatTimelineTime, getRoundedCurrentTimelineMinutes, + getTimelineDisabledRangePercentages, normalizeTimelineMinutes, } from "./timelineTime"; @@ -109,6 +110,10 @@ const Timeline: React.FC<TimelineProps> = ({ minTime, maxTime, ); + const disabledRangePercentages = getTimelineDisabledRangePercentages( + minTime, + maxTime, + ); useEffect(() => { if (schemeDate) { setSelectedDate(schemeDate); @@ -945,22 +950,28 @@ const Timeline: React.FC<TimelineProps> = ({ /> {/* 禁用区域遮罩 */} {timeRange && ( - <> + <Box + sx={{ + position: "absolute", + left: 16, + right: 16, + top: "30%", + transform: "translateY(-50%)", + height: "20px", + pointerEvents: "none", + }} + > {/* 左侧禁用区域 */} {minTime > 0 && ( <Box sx={{ position: "absolute", - left: "14px", - top: "30%", - transform: "translateY(-50%)", - width: `${(minTime / 1440) * 856 + 2}px`, - height: "20px", + left: 0, + width: `${disabledRangePercentages.leftWidth}%`, + height: "100%", backgroundColor: "rgba(189, 189, 189, 0.4)", - pointerEvents: "none", backdropFilter: "blur(1px)", borderRadius: "2.5px", - rounded: "true", }} /> )} @@ -969,19 +980,16 @@ const Timeline: React.FC<TimelineProps> = ({ <Box sx={{ position: "absolute", - left: `${16 + (maxTime / 1440) * 856}px`, - top: "30%", - transform: "translateY(-50%)", - width: `${((1440 - maxTime) / 1440) * 856}px`, - height: "20px", + left: `${disabledRangePercentages.rightLeft}%`, + width: `${disabledRangePercentages.rightWidth}%`, + height: "100%", backgroundColor: "rgba(189, 189, 189, 0.4)", - pointerEvents: "none", backdropFilter: "blur(1px)", borderRadius: "2.5px", }} /> )} - </> + </Box> )} </Box> </Box> diff --git a/src/components/olmap/core/Controls/timelineTime.test.ts b/src/components/olmap/core/Controls/timelineTime.test.ts index 2928845..b55cae7 100644 --- a/src/components/olmap/core/Controls/timelineTime.test.ts +++ b/src/components/olmap/core/Controls/timelineTime.test.ts @@ -1,6 +1,7 @@ import { formatTimelineTime, getRoundedCurrentTimelineMinutes, + getTimelineDisabledRangePercentages, normalizeTimelineMinutes, } from "./timelineTime"; @@ -18,4 +19,12 @@ describe("timelineTime", () => { it("rounds the current time down to the timeline step", () => { expect(getRoundedCurrentTimelineMinutes(new Date("2026-07-16T10:29:00"))).toBe(615); }); + + it("calculates disabled range as full-day percentages", () => { + expect(getTimelineDisabledRangePercentages(360, 1080)).toEqual({ + leftWidth: 25, + rightLeft: 75, + rightWidth: 25, + }); + }); }); diff --git a/src/components/olmap/core/Controls/timelineTime.ts b/src/components/olmap/core/Controls/timelineTime.ts index 42f17b0..7c2438c 100644 --- a/src/components/olmap/core/Controls/timelineTime.ts +++ b/src/components/olmap/core/Controls/timelineTime.ts @@ -30,3 +30,18 @@ export const formatTimelineTime = ( .toString() .padStart(2, "0")}`; }; + +export const getTimelineDisabledRangePercentages = ( + minTime: number, + maxTime: number, +) => { + const rangeStart = normalizeTimelineMinutes(minTime); + const rangeEnd = normalizeTimelineMinutes(maxTime); + + return { + leftWidth: (rangeStart / TIMELINE_MINUTES_PER_DAY) * 100, + rightLeft: (rangeEnd / TIMELINE_MINUTES_PER_DAY) * 100, + rightWidth: + ((TIMELINE_MINUTES_PER_DAY - rangeEnd) / TIMELINE_MINUTES_PER_DAY) * 100, + }; +}; -- 2.54.0 From dfaee645ffbe1c403bcd1165f0e43534ef27b1fc Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 14:16:07 +0800 Subject: [PATCH 221/281] fix(timeline): use backend timestep --- .../olmap/HealthRiskAnalysis/Timeline.tsx | 30 ++-- .../olmap/core/Controls/Timeline.tsx | 155 +++++++++++------- .../olmap/core/Controls/timelineTime.test.ts | 24 +++ .../olmap/core/Controls/timelineTime.ts | 61 ++++++- .../Controls/useTimelineTimeConfig.test.tsx | 69 ++++++++ .../core/Controls/useTimelineTimeConfig.ts | 105 ++++++++++++ src/components/olmap/core/MapComponent.tsx | 8 +- 7 files changed, 370 insertions(+), 82 deletions(-) create mode 100644 src/components/olmap/core/Controls/useTimelineTimeConfig.test.tsx create mode 100644 src/components/olmap/core/Controls/useTimelineTimeConfig.ts diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index 9f31964..c169883 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -30,6 +30,7 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi"; import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; import { useMap } from "@components/olmap/core/MapComponent"; +import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig"; import { useHealthRisk } from "./HealthRiskContext"; import { PredictionResult, @@ -38,10 +39,11 @@ import { RISK_BREAKS, } from "./types"; -// 辅助函数:将日期向下取整到最近的15分钟 -const getRoundedDate = (date: Date): Date => { +// 辅助函数:将日期向下取整到配置的水力时间步长 +const getRoundedDate = (date: Date, stepMinutes: number): Date => { + const safeStep = stepMinutes > 0 ? stepMinutes : 15; const minutes = date.getHours() * 60 + date.getMinutes(); - const roundedMinutes = Math.floor(minutes / 15) * 15; + const roundedMinutes = Math.floor(minutes / safeStep) * safeStep; const roundedDate = new Date(date); roundedDate.setHours( Math.floor(roundedMinutes / 60), @@ -91,6 +93,7 @@ const Timeline: React.FC<TimelineProps> = ({ const [isPlaying, setIsPlaying] = useState<boolean>(false); const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒 const [isPredicting, setIsPredicting] = useState<boolean>(false); + const { stepMinutes } = useTimelineTimeConfig(); // 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定 const healthDataRef = useRef<Map<string, number>>(new Map()); @@ -200,11 +203,14 @@ const Timeline: React.FC<TimelineProps> = ({ }, [minTime, maxTime, setCurrentYear]); // 日期时间选择处理 - const handleDateTimeChange = useCallback((newDate: Date | null) => { - if (newDate) { - setSelectedDateTime(getRoundedDate(newDate)); - } - }, []); + const handleDateTimeChange = useCallback( + (newDate: Date | null) => { + if (newDate) { + setSelectedDateTime(getRoundedDate(newDate, stepMinutes)); + } + }, + [stepMinutes], + ); // 播放间隔改变处理 const handleIntervalChange = useCallback( @@ -227,9 +233,9 @@ const Timeline: React.FC<TimelineProps> = ({ [isPlaying, maxTime, minTime, setCurrentYear], ); - // 组件加载时设置初始时间为当前时间的最近15分钟 + // 组件加载时设置初始时间为当前时间的配置时间步长 useEffect(() => { - setSelectedDateTime(getRoundedDate(new Date())); + setSelectedDateTime(getRoundedDate(new Date(), stepMinutes)); return () => { if (intervalRef.current) { @@ -239,7 +245,7 @@ const Timeline: React.FC<TimelineProps> = ({ clearTimeout(debounceRef.current); } }; - }, []); + }, [stepMinutes]); // 获取地图实例 const map = useMap(); @@ -513,7 +519,7 @@ const Timeline: React.FC<TimelineProps> = ({ } format="YYYY-MM-DD HH:mm" views={["year", "month", "day", "hours", "minutes"]} - minutesStep={15} + minutesStep={stepMinutes} sx={{ width: 200 }} slotProps={{ textField: { diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index 02d15fb..e7d5935 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useNotification } from "@refinedev/core"; import Draggable from "react-draggable"; @@ -36,6 +36,7 @@ import { getTimelineDisabledRangePercentages, normalizeTimelineMinutes, } from "./timelineTime"; +import { useTimelineTimeConfig } from "./useTimelineTimeConfig"; interface TimelineProps { schemeDate?: Date; @@ -64,6 +65,13 @@ const timelineIconButtonSx = { const NOOP_SET_CURRENT_TIME = (_: any) => undefined; const NOOP_SET_SELECTED_DATE = (_: any) => undefined; +const BASE_CALCULATED_INTERVAL_OPTIONS = [ + { value: 1440, label: "1 天" }, + { value: 60, label: "1 小时" }, + { value: 30, label: "30 分钟" }, + { value: 15, label: "15 分钟" }, + { value: 5, label: "5 分钟" }, +]; const Timeline: React.FC<TimelineProps> = ({ schemeDate, @@ -93,18 +101,28 @@ const Timeline: React.FC<TimelineProps> = ({ const pipeText = data?.pipeText ?? ""; const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion; const { open } = useNotification(); + const { durationMinutes, stepMinutes } = useTimelineTimeConfig(); const [isPlaying, setIsPlaying] = useState<boolean>(false); const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒 - const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟 + const [calculatedInterval, setCalculatedInterval] = + useState<number>(stepMinutes); // 分钟 const [isCalculating, setIsCalculating] = useState<boolean>(false); // 计算时间轴范围 const minTime = timeRange - ? timeRange.start.getHours() * 60 + timeRange.start.getMinutes() + ? normalizeTimelineMinutes( + timeRange.start.getHours() * 60 + timeRange.start.getMinutes(), + 0, + durationMinutes, + ) : 0; const maxTime = timeRange - ? timeRange.end.getHours() * 60 + timeRange.end.getMinutes() - : 1440; + ? normalizeTimelineMinutes( + timeRange.end.getHours() * 60 + timeRange.end.getMinutes(), + 0, + durationMinutes, + ) + : durationMinutes; const timelineCurrentTime = normalizeTimelineMinutes( currentTime, minTime, @@ -113,6 +131,7 @@ const Timeline: React.FC<TimelineProps> = ({ const disabledRangePercentages = getTimelineDisabledRangePercentages( minTime, maxTime, + durationMinutes, ); useEffect(() => { if (schemeDate) { @@ -334,16 +353,28 @@ const Timeline: React.FC<TimelineProps> = ({ [disableDateSelection, fetchDataBySource, isCompareMode] ); - // 时间刻度数组 (每5分钟一个刻度) - const timeMarks = Array.from({ length: 288 }, (_, i) => ({ - value: i * 5, - label: i % 24 === 0 ? formatTime(i * 5) : "", - })); - // 格式化时间显示 - function formatTime(minutes: number): string { + const formatTime = useCallback((minutes: number): string => { return formatTimelineTime(minutes, minTime, maxTime); - } + }, [maxTime, minTime]); + + const timeMarks = useMemo(() => { + const marks = []; + const markInterval = 120; + for (let value = 0; value <= durationMinutes; value += markInterval) { + marks.push({ + value, + label: formatTime(value), + }); + } + if (marks.at(-1)?.value !== durationMinutes) { + marks.push({ + value: durationMinutes, + label: formatTime(durationMinutes), + }); + } + return marks; + }, [durationMinutes, formatTime]); const currentTimeToDate = useCallback((selectedDate: Date, minutes: number): Date => { const date = new Date(selectedDate); @@ -362,13 +393,31 @@ const Timeline: React.FC<TimelineProps> = ({ { value: 20000, label: "20秒" }, ]; // 强制计算时间段选项 - const calculatedIntervalOptions = [ - { value: 1440, label: "1 天" }, - { value: 60, label: "1 小时" }, - { value: 30, label: "30 分钟" }, - { value: 15, label: "15 分钟" }, - { value: 5, label: "5 分钟" }, - ]; + const resolvedCalculatedIntervalOptions = useMemo(() => { + const options = BASE_CALCULATED_INTERVAL_OPTIONS.filter( + (option) => option.value >= stepMinutes, + ); + if (!options.some((option) => option.value === stepMinutes)) { + options.push({ value: stepMinutes, label: `${stepMinutes} 分钟` }); + } + return options.sort((a, b) => b.value - a.value); + }, [stepMinutes]); + + const advanceTimelineTime = useCallback( + (previousTime: number, direction: 1 | -1 = 1) => { + const baseTime = Number.isFinite(previousTime) ? previousTime : minTime; + let next = baseTime + stepMinutes * direction; + if (timeRange) { + if (next > maxTime) next = minTime; + if (next < minTime) next = maxTime; + } else { + if (next > durationMinutes) next = 0; + if (next < 0) next = durationMinutes; + } + return next; + }, + [durationMinutes, maxTime, minTime, stepMinutes, timeRange], + ); // 处理时间轴滑动 const handleSliderChange = useCallback( @@ -403,17 +452,11 @@ const Timeline: React.FC<TimelineProps> = ({ intervalRef.current = setInterval(() => { setCurrentTime((prev) => { - let next = prev + 15; - if (timeRange) { - if (next > maxTime) next = minTime; - } else { - if (next >= 1440) next = 0; - } - return next; + return advanceTimelineTime(prev); }); }, playInterval); } - }, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]); + }, [advanceTimelineTime, isPlaying, playInterval, setCurrentTime]); const handlePause = useCallback(() => { setIsPlaying(false); @@ -426,12 +469,14 @@ const Timeline: React.FC<TimelineProps> = ({ const handleStop = useCallback(() => { setIsPlaying(false); // 设置为当前时间 - setCurrentTime(getRoundedCurrentTimelineMinutes()); // 重置为当前时间 + setCurrentTime( + getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes), + ); // 重置为当前时间 if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } - }, [setCurrentTime]); + }, [durationMinutes, setCurrentTime, stepMinutes]); // 步进控制 const handleDayStepBackward = useCallback(() => { @@ -450,27 +495,15 @@ const Timeline: React.FC<TimelineProps> = ({ }, [setSelectedDate]); const handleStepBackward = useCallback(() => { setCurrentTime((prev) => { - let next = prev - 15; - if (timeRange) { - if (next < minTime) next = maxTime; - } else { - if (next < 0) next += 1440; - } - return next; + return advanceTimelineTime(prev, -1); }); - }, [timeRange, minTime, maxTime, setCurrentTime]); + }, [advanceTimelineTime, setCurrentTime]); const handleStepForward = useCallback(() => { setCurrentTime((prev) => { - let next = prev + 15; - if (timeRange) { - if (next > maxTime) next = minTime; - } else { - if (next >= 1440) next = 0; - } - return next; + return advanceTimelineTime(prev); }); - }, [timeRange, minTime, maxTime, setCurrentTime]); + }, [advanceTimelineTime, setCurrentTime]); // 日期选择处理 const handleDateChange = useCallback((newDate: Date | null) => { @@ -490,18 +523,12 @@ const Timeline: React.FC<TimelineProps> = ({ clearInterval(intervalRef.current); intervalRef.current = setInterval(() => { setCurrentTime((prev) => { - let next = prev + 15; - if (timeRange) { - if (next > maxTime) next = minTime; - } else { - if (next >= 1440) next = 0; - } - return next; + return advanceTimelineTime(prev); }); }, newInterval); } }, - [isPlaying, timeRange, maxTime, minTime, setCurrentTime], + [advanceTimelineTime, isPlaying, setCurrentTime], ); // 计算时间段改变处理 const handleCalculatedIntervalChange = useCallback((event: any) => { @@ -509,6 +536,10 @@ const Timeline: React.FC<TimelineProps> = ({ setCalculatedInterval(newInterval); }, []); + useEffect(() => { + setCalculatedInterval(stepMinutes); + }, [stepMinutes]); + // 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据 useEffect(() => { // 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错 @@ -546,7 +577,9 @@ const Timeline: React.FC<TimelineProps> = ({ // 组件卸载时清理定时器和防抖 useEffect(() => { // 设置为当前时间 - setCurrentTime(getRoundedCurrentTimelineMinutes()); // 初始化为当前时间 + setCurrentTime( + getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes), + ); // 初始化为当前时间 return () => { if (intervalRef.current) { @@ -556,7 +589,7 @@ const Timeline: React.FC<TimelineProps> = ({ clearTimeout(debounceRef.current); } }; - }, [setCurrentTime]); + }, [durationMinutes, setCurrentTime, stepMinutes]); // 当 timeRange 改变时,设置 currentTime 到 minTime useEffect(() => { @@ -868,7 +901,7 @@ const Timeline: React.FC<TimelineProps> = ({ label="强制计算时间段" onChange={handleCalculatedIntervalChange} > - {calculatedIntervalOptions.map((option) => ( + {resolvedCalculatedIntervalOptions.map((option) => ( <MenuItem key={option.value} value={option.value}> {option.label} </MenuItem> @@ -906,9 +939,9 @@ const Timeline: React.FC<TimelineProps> = ({ <Slider value={timelineCurrentTime} min={0} - max={1440} // 24:00 = 1440分钟 - step={15} // 每15分钟一个步进 - marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记 + max={durationMinutes} + step={stepMinutes} + marks={timeMarks} onChange={handleSliderChange} valueLabelDisplay="auto" valueLabelFormat={formatTime} @@ -976,7 +1009,7 @@ const Timeline: React.FC<TimelineProps> = ({ /> )} {/* 右侧禁用区域 */} - {maxTime < 1440 && ( + {maxTime < durationMinutes && ( <Box sx={{ position: "absolute", diff --git a/src/components/olmap/core/Controls/timelineTime.test.ts b/src/components/olmap/core/Controls/timelineTime.test.ts index b55cae7..bb9013c 100644 --- a/src/components/olmap/core/Controls/timelineTime.test.ts +++ b/src/components/olmap/core/Controls/timelineTime.test.ts @@ -1,8 +1,10 @@ import { + coerceTimelineDurationMinutes, formatTimelineTime, getRoundedCurrentTimelineMinutes, getTimelineDisabledRangePercentages, normalizeTimelineMinutes, + parseTimelineDurationMinutes, } from "./timelineTime"; describe("timelineTime", () => { @@ -20,6 +22,28 @@ describe("timelineTime", () => { expect(getRoundedCurrentTimelineMinutes(new Date("2026-07-16T10:29:00"))).toBe(615); }); + it("rounds the current time down to a custom timeline step", () => { + expect( + getRoundedCurrentTimelineMinutes( + new Date("2026-07-16T10:29:00"), + 60, + ), + ).toBe(600); + }); + + it("parses EPANET-style duration values as minutes", () => { + expect(parseTimelineDurationMinutes("0:05")).toBe(5); + expect(parseTimelineDurationMinutes("1:00")).toBe(60); + expect(parseTimelineDurationMinutes("24:00")).toBe(1440); + expect(parseTimelineDurationMinutes("0:05:00")).toBe(5); + }); + + it("falls back for invalid, missing, and zero duration values", () => { + expect(coerceTimelineDurationMinutes("invalid", 1440)).toBe(1440); + expect(coerceTimelineDurationMinutes(undefined, 1440)).toBe(1440); + expect(coerceTimelineDurationMinutes("0:00", 1440)).toBe(1440); + }); + it("calculates disabled range as full-day percentages", () => { expect(getTimelineDisabledRangePercentages(360, 1080)).toEqual({ leftWidth: 25, diff --git a/src/components/olmap/core/Controls/timelineTime.ts b/src/components/olmap/core/Controls/timelineTime.ts index 7c2438c..a997371 100644 --- a/src/components/olmap/core/Controls/timelineTime.ts +++ b/src/components/olmap/core/Controls/timelineTime.ts @@ -1,6 +1,41 @@ export const TIMELINE_MINUTES_PER_DAY = 1440; export const TIMELINE_STEP_MINUTES = 15; +export const parseTimelineDurationMinutes = ( + value: unknown, +): number | undefined => { + if (typeof value !== "string") { + return undefined; + } + + const parts = value + .trim() + .split(":") + .map((part) => Number(part)); + + if ( + (parts.length !== 2 && parts.length !== 3) || + parts.some((part) => !Number.isFinite(part) || part < 0) + ) { + return undefined; + } + + const [hours, minutes, seconds = 0] = parts; + if (minutes >= 60 || seconds >= 60) { + return undefined; + } + + return hours * 60 + minutes + Math.floor(seconds / 60); +}; + +export const coerceTimelineDurationMinutes = ( + value: unknown, + fallbackMinutes: number, +) => { + const minutes = parseTimelineDurationMinutes(value); + return minutes && minutes > 0 ? minutes : fallbackMinutes; +}; + export const normalizeTimelineMinutes = ( minutes: number | undefined, minTime = 0, @@ -13,9 +48,18 @@ export const normalizeTimelineMinutes = ( return Math.min(Math.max(minutes, minTime), maxTime); }; -export const getRoundedCurrentTimelineMinutes = (date = new Date()) => { +export const getRoundedCurrentTimelineMinutes = ( + date = new Date(), + stepMinutes = TIMELINE_STEP_MINUTES, + maxMinutes = TIMELINE_MINUTES_PER_DAY, +) => { + const safeStep = stepMinutes > 0 ? stepMinutes : TIMELINE_STEP_MINUTES; const minutes = date.getHours() * 60 + date.getMinutes(); - return Math.floor(minutes / TIMELINE_STEP_MINUTES) * TIMELINE_STEP_MINUTES; + return normalizeTimelineMinutes( + Math.floor(minutes / safeStep) * safeStep, + 0, + maxMinutes, + ); }; export const formatTimelineTime = ( @@ -34,14 +78,17 @@ export const formatTimelineTime = ( export const getTimelineDisabledRangePercentages = ( minTime: number, maxTime: number, + totalMinutes = TIMELINE_MINUTES_PER_DAY, ) => { - const rangeStart = normalizeTimelineMinutes(minTime); - const rangeEnd = normalizeTimelineMinutes(maxTime); + const safeTotalMinutes = + totalMinutes > 0 ? totalMinutes : TIMELINE_MINUTES_PER_DAY; + const rangeStart = normalizeTimelineMinutes(minTime, 0, safeTotalMinutes); + const rangeEnd = normalizeTimelineMinutes(maxTime, 0, safeTotalMinutes); return { - leftWidth: (rangeStart / TIMELINE_MINUTES_PER_DAY) * 100, - rightLeft: (rangeEnd / TIMELINE_MINUTES_PER_DAY) * 100, + leftWidth: (rangeStart / safeTotalMinutes) * 100, + rightLeft: (rangeEnd / safeTotalMinutes) * 100, rightWidth: - ((TIMELINE_MINUTES_PER_DAY - rangeEnd) / TIMELINE_MINUTES_PER_DAY) * 100, + ((safeTotalMinutes - rangeEnd) / safeTotalMinutes) * 100, }; }; diff --git a/src/components/olmap/core/Controls/useTimelineTimeConfig.test.tsx b/src/components/olmap/core/Controls/useTimelineTimeConfig.test.tsx new file mode 100644 index 0000000..c04fa12 --- /dev/null +++ b/src/components/olmap/core/Controls/useTimelineTimeConfig.test.tsx @@ -0,0 +1,69 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { useTimelineTimeConfig, toTimelineTimeConfig } from "./useTimelineTimeConfig"; + +const apiFetch = jest.fn(); + +jest.mock("@/lib/apiFetch", () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +jest.mock("@/contexts/ProjectContext", () => ({ + useProject: () => ({ networkName: "test-network" }), +})); + +describe("useTimelineTimeConfig", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("derives duration and step from backend time properties", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + DURATION: "24:00", + "HYDRAULIC TIMESTEP": "1:00", + }), + }); + + const { result } = renderHook(() => useTimelineTimeConfig()); + + await waitFor(() => { + expect(result.current.stepMinutes).toBe(60); + }); + expect(result.current.durationMinutes).toBe(1440); + expect(String(apiFetch.mock.calls[0][0])).toContain( + "/api/v1/gettimeproperties/?network=test-network", + ); + }); + + it("falls back when fetching time properties fails", async () => { + apiFetch.mockRejectedValueOnce(new Error("network failure")); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const { result } = renderHook(() => useTimelineTimeConfig()); + + await waitFor(() => { + expect(apiFetch).toHaveBeenCalledTimes(1); + }); + expect(result.current).toMatchObject({ + durationMinutes: 1440, + stepMinutes: 15, + }); + + warnSpy.mockRestore(); + }); + + it("parses wrapped times payloads", () => { + expect( + toTimelineTimeConfig({ + times: { + DURATION: "24:00", + "HYDRAULIC TIMESTEP": "0:05", + }, + }), + ).toMatchObject({ + durationMinutes: 1440, + stepMinutes: 5, + }); + }); +}); diff --git a/src/components/olmap/core/Controls/useTimelineTimeConfig.ts b/src/components/olmap/core/Controls/useTimelineTimeConfig.ts new file mode 100644 index 0000000..3249e5d --- /dev/null +++ b/src/components/olmap/core/Controls/useTimelineTimeConfig.ts @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { config, NETWORK_NAME } from "@/config/config"; +import { useProject } from "@/contexts/ProjectContext"; +import { apiFetch } from "@/lib/apiFetch"; +import { + coerceTimelineDurationMinutes, + TIMELINE_MINUTES_PER_DAY, + TIMELINE_STEP_MINUTES, +} from "./timelineTime"; + +export interface TimelineTimeConfig { + durationMinutes: number; + stepMinutes: number; + properties: Record<string, unknown>; +} + +const DEFAULT_TIMELINE_TIME_CONFIG: TimelineTimeConfig = { + durationMinutes: TIMELINE_MINUTES_PER_DAY, + stepMinutes: TIMELINE_STEP_MINUTES, + properties: {}, +}; + +const resolveTimeProperties = (data: unknown): Record<string, unknown> => { + if (!data || typeof data !== "object") { + return {}; + } + + const record = data as Record<string, unknown>; + if (record.times && typeof record.times === "object") { + return record.times as Record<string, unknown>; + } + + return record; +}; + +export const toTimelineTimeConfig = (data: unknown): TimelineTimeConfig => { + const properties = resolveTimeProperties(data); + + return { + durationMinutes: coerceTimelineDurationMinutes( + properties.DURATION, + TIMELINE_MINUTES_PER_DAY, + ), + stepMinutes: coerceTimelineDurationMinutes( + properties["HYDRAULIC TIMESTEP"], + TIMELINE_STEP_MINUTES, + ), + properties, + }; +}; + +export const useTimelineTimeConfig = (): TimelineTimeConfig => { + const project = useProject(); + const networkName = project?.networkName || NETWORK_NAME; + const [timeConfig, setTimeConfig] = useState<TimelineTimeConfig>( + DEFAULT_TIMELINE_TIME_CONFIG, + ); + + useEffect(() => { + if (!networkName) { + setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG); + return; + } + + let isMounted = true; + const fetchTimeProperties = async () => { + try { + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/gettimeproperties/?network=${encodeURIComponent( + networkName, + )}`, + ); + if (!response.ok) { + throw new Error(`Failed to fetch time properties: ${response.status}`); + } + const data = await response.json(); + if (isMounted) { + setTimeConfig(toTimelineTimeConfig(data)); + } + } catch (error) { + console.warn("Failed to load timeline time properties:", error); + if (isMounted) { + setTimeConfig(DEFAULT_TIMELINE_TIME_CONFIG); + } + } + }; + + fetchTimeProperties(); + + return () => { + isMounted = false; + }; + }, [networkName]); + + return useMemo( + () => ({ + durationMinutes: timeConfig.durationMinutes, + stepMinutes: timeConfig.stepMinutes, + properties: timeConfig.properties, + }), + [timeConfig], + ); +}; diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index b757a37..6829155 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -33,6 +33,7 @@ import { } from "./mapLifecycle"; import { createOperationalMapResources } from "./operationalLayers"; import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime"; +import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig"; interface MapComponentProps { children?: React.ReactNode; @@ -140,6 +141,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { ]; const MAP_URL = config.MAP_URL; const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key + const { durationMinutes, stepMinutes } = useTimelineTimeConfig(); const mapRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null); @@ -778,7 +780,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }, [compareMap, isCompareMode, map]); useEffect(() => { - setCurrentTime(getRoundedCurrentTimelineMinutes()); + setCurrentTime( + getRoundedCurrentTimelineMinutes(new Date(), stepMinutes, durationMinutes), + ); setSelectedDate(new Date()); setSchemeName(""); setCurrentJunctionCalData([]); @@ -803,7 +807,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { deckLayerRef.current?.resetSessionLayers(); operationalResources.resetStyles(); }; - }, [pathname, map, operationalResources]); + }, [durationMinutes, pathname, map, operationalResources, stepMinutes]); // 当数据变化时,更新 deck.gl 图层 useEffect(() => { -- 2.54.0 From 209da0d29502b1649731f02bbadf14619e55aeb3 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 14:29:29 +0800 Subject: [PATCH 222/281] fix(map): sort scheme queries by time --- .../BurstDetection/AnalysisParameters.tsx | 3 +++ .../olmap/BurstDetection/SchemeQuery.tsx | 18 ++++++++++++++---- .../olmap/BurstLocation/AnalysisParameters.tsx | 3 +++ .../olmap/BurstLocation/SchemeQuery.tsx | 18 ++++++++++++++---- .../olmap/BurstSimulation/SchemeQuery.tsx | 8 +++++++- .../ContaminantSimulation/SchemeQuery.tsx | 8 +++++++- .../olmap/DMALeakDetection/SchemeQuery.tsx | 18 ++++++++++++++---- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 18 ++++++++++++++---- .../SchemeQuery.tsx | 18 ++++++++++++++---- 9 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index 864b3e0..4a11577 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -124,6 +124,9 @@ const AnalysisParameters: React.FC<Props> = ({ }); const burstSchemes = (response.data as SchemeItem[]).filter( (scheme) => scheme.scheme_type === "burst_analysis", + ).sort( + (a, b) => + dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), ); setFormField("schemes", burstSchemes); diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx index c773b0e..6a44e7a 100644 --- a/src/components/olmap/BurstDetection/SchemeQuery.tsx +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Box, Button, @@ -69,6 +69,16 @@ const SchemeQuery: React.FC<Props> = ({ const [loading, setLoading] = useState(false); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; + const sortedSchemes = useMemo( + () => + schemes + .slice() + .sort( + (a, b) => + dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), + ), + [schemes], + ); const buildDisplayResult = ( scheme: Pick<BurstDetectionSchemeRecord, "scheme_name" | "username" | "create_time">, @@ -214,7 +224,7 @@ const SchemeQuery: React.FC<Props> = ({ </Box> <Box className="flex-1 overflow-auto"> - {schemes.length === 0 ? ( + {sortedSchemes.length === 0 ? ( <Box className="flex h-full flex-col items-center justify-center text-center text-gray-400"> <Typography variant="body2">暂无侦测方案</Typography> <Typography variant="caption" className="mt-1"> @@ -224,9 +234,9 @@ const SchemeQuery: React.FC<Props> = ({ ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="px-2 text-gray-500"> - 共 {schemes.length} 条记录 + 共 {sortedSchemes.length} 条记录 </Typography> - {schemes.map((scheme) => { + {sortedSchemes.map((scheme) => { const summary = scheme.scheme_detail?.result_summary; const payload = scheme.scheme_detail?.result_payload; const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false; diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 4a872c2..c8d955b 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -128,6 +128,9 @@ const AnalysisParameters: React.FC<Props> = ({ }); const burstSchemes = (response.data as SchemeItem[]).filter( (scheme) => scheme.scheme_type === "burst_analysis", + ).sort( + (a, b) => + dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), ); setFormField("schemes", burstSchemes); diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index d83ccca..a3e29a4 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Box, Button, @@ -70,6 +70,16 @@ const SchemeQuery: React.FC<Props> = ({ const [loading, setLoading] = useState(false); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; + const sortedSchemes = useMemo( + () => + schemes + .slice() + .sort( + (a, b) => + dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), + ), + [schemes], + ); const buildDisplayResult = ( scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">, @@ -211,7 +221,7 @@ const SchemeQuery: React.FC<Props> = ({ </Box> </Box> <Box className="flex-1 overflow-auto"> - {schemes.length === 0 ? ( + {sortedSchemes.length === 0 ? ( <Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="mb-4"> <svg @@ -248,9 +258,9 @@ const SchemeQuery: React.FC<Props> = ({ ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> - 共 {schemes.length} 条记录 + 共 {sortedSchemes.length} 条记录 </Typography> - {schemes.map((scheme) => { + {sortedSchemes.map((scheme) => { const summary = scheme.scheme_detail?.result_summary; const payload = scheme.scheme_detail?.result_payload; const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-"; diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index d2ab883..a92d6b1 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -128,7 +128,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ }; const filteredSchemes = useMemo(() => { - return schemes.filter((scheme) => scheme.type === SCHEME_TYPE); + return schemes + .filter((scheme) => scheme.type === SCHEME_TYPE) + .slice() + .sort( + (a, b) => + moment(b.create_time).valueOf() - moment(a.create_time).valueOf(), + ); }, [schemes]); const handleQuery = async () => { diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index ceac84c..4797660 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -202,7 +202,13 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ }; const filteredSchemes = useMemo(() => { - return schemes.filter((scheme) => scheme.type === SCHEME_TYPE); + return schemes + .filter((scheme) => scheme.type === SCHEME_TYPE) + .slice() + .sort( + (a, b) => + moment(b.create_time).valueOf() - moment(a.create_time).valueOf(), + ); }, [schemes]); const handleQuery = async () => { diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index 39ad5e6..bae1d39 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Box, Button, @@ -65,6 +65,16 @@ const SchemeQuery: React.FC<Props> = ({ const [loading, setLoading] = useState(false); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; + const sortedSchemes = useMemo( + () => + schemes + .slice() + .sort( + (a, b) => + dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), + ), + [schemes], + ); const handleQuery = async () => { setLoading(true); @@ -144,7 +154,7 @@ const SchemeQuery: React.FC<Props> = ({ </Box> </Box> <Box className="flex-1 overflow-auto"> - {schemes.length === 0 ? ( + {sortedSchemes.length === 0 ? ( <Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="mb-4"> <svg @@ -181,9 +191,9 @@ const SchemeQuery: React.FC<Props> = ({ ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> - 共 {schemes.length} 条记录 + 共 {sortedSchemes.length} 条记录 </Typography> - {schemes.map((scheme) => ( + {sortedSchemes.map((scheme) => ( <Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow"> <CardContent className="p-3 pb-2 last:pb-3"> <Box className="flex items-start justify-between gap-2 mb-2"> diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index ce17de7..256b5b3 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import ReactDOM from "react-dom"; import { @@ -108,6 +108,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; + const sortedSchemes = useMemo( + () => + schemes + .slice() + .sort( + (a, b) => + moment(b.create_time).valueOf() - moment(a.create_time).valueOf(), + ), + [schemes], + ); useEffect(() => { if (!map) return; @@ -387,7 +397,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ {/* Results List */} <Box className="flex-1 overflow-auto"> - {schemes.length === 0 ? ( + {sortedSchemes.length === 0 ? ( <Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="mb-4"> <svg @@ -424,9 +434,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> - 共 {schemes.length} 条记录 + 共 {sortedSchemes.length} 条记录 </Typography> - {schemes.map((scheme) => ( + {sortedSchemes.map((scheme) => ( <Card key={scheme.id} variant="outlined" diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 97fe3c0..cec0973 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, @@ -107,6 +107,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; + const sortedSchemes = useMemo( + () => + schemes + .slice() + .sort( + (a, b) => + moment(b.create_time).valueOf() - moment(a.create_time).valueOf(), + ), + [schemes], + ); // 格式化简短日期 const formatShortDate = (timeStr: string) => { @@ -313,7 +323,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ {/* 结果列表 */} <Box className="flex-1 overflow-auto"> - {schemes.length === 0 ? ( + {sortedSchemes.length === 0 ? ( <Box className="flex flex-col items-center justify-center h-full text-gray-400"> <Box className="mb-4"> <svg @@ -350,9 +360,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> - 共 {schemes.length} 条记录 + 共 {sortedSchemes.length} 条记录 </Typography> - {schemes.map((scheme) => ( + {sortedSchemes.map((scheme) => ( <Card key={scheme.id} variant="outlined" -- 2.54.0 From ef2b045306c4fffcc42cd5d9a59d3e1dd747d205 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 14:50:22 +0800 Subject: [PATCH 223/281] fix(map): show simulation pipe ids --- .../BurstLocation/AnalysisParameters.tsx | 28 ++- .../olmap/BurstLocation/LocationResults.tsx | 52 ++++ .../olmap/BurstLocation/SchemeQuery.tsx | 225 +++++++++++++++++- src/components/olmap/BurstLocation/types.ts | 1 + 4 files changed, 300 insertions(+), 6 deletions(-) diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index c8d955b..eefd57d 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -43,6 +43,7 @@ export interface SchemeItem { scheme_start_time: string; scheme_detail?: { modify_total_duration: number; + burst_ID?: string[] | string; }; } @@ -237,7 +238,24 @@ const AnalysisParameters: React.FC<Props> = ({ }, ); - onResult(response.data as BurstLocationResult); + const resultPayload = response.data as BurstLocationResult; + const selectedBurstIds = normalizeBurstIds(selectedScheme?.scheme_detail?.burst_ID); + onResult( + selectedBurstIds.length > 0 + ? { + ...resultPayload, + simulation_scheme: { + ...resultPayload.simulation_scheme, + name: resultPayload.simulation_scheme?.name ?? selectedScheme?.scheme_name, + type: resultPayload.simulation_scheme?.type ?? selectedScheme?.scheme_type, + burst_ids: + resultPayload.simulation_scheme?.burst_ids?.length + ? resultPayload.simulation_scheme.burst_ids + : selectedBurstIds, + }, + } + : resultPayload, + ); open?.({ key: "burst-location-analysis-success", type: "success", @@ -503,3 +521,11 @@ const AnalysisParameters: React.FC<Props> = ({ }; export default AnalysisParameters; + +const normalizeBurstIds = (value: string[] | string | undefined) => { + if (!value) return []; + const values = Array.isArray(value) ? value : [value]; + return Array.from( + new Set(values.map((item) => String(item).trim()).filter(Boolean)), + ); +}; diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index c654403..a697b4d 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -13,6 +13,7 @@ import { TableHead, TableRow, Button, + Link, } from "@mui/material"; import { FormatListBulleted, @@ -251,6 +252,7 @@ const LocationResults: React.FC<Props> = ({ result }) => { ); const sourceLabel = getDataSourceLabel(result); const normalDataDescription = getNormalDataDescription(result); + const simulationBurstIds = result.simulation_scheme?.burst_ids ?? []; return ( <Box className="h-full overflow-auto p-1"> @@ -287,6 +289,7 @@ const LocationResults: React.FC<Props> = ({ result }) => { variant="outlined" startIcon={<LocationOnIcon />} onClick={() => locatePipes([result.located_pipe])} + disabled={!result.located_pipe} sx={{ height: 24, minWidth: 0, @@ -348,6 +351,55 @@ const LocationResults: React.FC<Props> = ({ result }) => { 爆管方案: {result.simulation_scheme.name} </Typography> ) : null} + {simulationBurstIds.length > 0 ? ( + <Box className="mt-1 flex flex-wrap items-center gap-1"> + <Typography variant="caption" className="text-purple-600"> + 模拟管段: + </Typography> + {simulationBurstIds.map((pipeId) => ( + <Link + key={pipeId} + component="button" + variant="caption" + onClick={() => locatePipes([pipeId])} + title={pipeId} + sx={{ + maxWidth: 132, + color: "#7c3aed", + fontSize: "0.75rem", + fontWeight: 700, + lineHeight: "22px", + cursor: "pointer", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + textDecoration: "underline", + textUnderlineOffset: "2px", + "&:hover": { + color: "#5b21b6", + }, + }} + > + {pipeId} + </Link> + ))} + <Button + size="small" + variant="text" + startIcon={<LocationOnIcon />} + onClick={() => locatePipes(simulationBurstIds)} + sx={{ + minWidth: 0, + px: 0.5, + py: 0, + color: "#7c3aed", + fontSize: "0.72rem", + }} + > + 定位全部 + </Button> + </Box> + ) : null} </Box> </Box> diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index a3e29a4..c64cd58 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, @@ -13,8 +13,12 @@ import { IconButton, Tooltip, Typography, + Link, } from "@mui/material"; -import { Info as InfoIcon } from "@mui/icons-material"; +import { + Info as InfoIcon, + LocationOn as LocationOnIcon, +} from "@mui/icons-material"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; @@ -24,6 +28,14 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { useMap } from "@components/olmap/core/MapComponent"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { GeoJSON } from "ol/format"; +import Feature from "ol/Feature"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Stroke, Style, Circle, Fill } from "ol/style"; +import { bbox, featureCollection } from "@turf/turf"; import { BurstLocationResult, BurstLocationSchemeDetail, @@ -43,6 +55,7 @@ export interface BurstLocationSchemeQueryState { queryAll: boolean; queryDate: Dayjs | null; expandedId: number | null; + simulationBurstIdsByName: Record<string, string[]>; } export const createBurstLocationSchemeQueryState = @@ -50,6 +63,7 @@ export const createBurstLocationSchemeQueryState = queryAll: true, queryDate: dayjs(), expandedId: null, + simulationBurstIdsByName: {}, }); const SchemeQuery: React.FC<Props> = ({ @@ -60,12 +74,16 @@ const SchemeQuery: React.FC<Props> = ({ onStateChange, }) => { const { open } = useNotification(); + const map = useMap(); + const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); + const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [queryState, , setQueryField] = useControllableObjectState( state, onStateChange, createBurstLocationSchemeQueryState(), ); const { queryAll, queryDate, expandedId } = queryState; + const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {}; const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [loading, setLoading] = useState(false); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; @@ -81,6 +99,92 @@ const SchemeQuery: React.FC<Props> = ({ [schemes], ); + useEffect(() => { + if (!map) return; + + const layer = new VectorLayer({ + source: new VectorSource(), + style: new Style({ + stroke: new Stroke({ + color: "#a855f7", + width: 6, + }), + image: new Circle({ + radius: 8, + fill: new Fill({ color: "#a855f7" }), + stroke: new Stroke({ color: "#fff", width: 2 }), + }), + }), + properties: { + name: "爆管定位模拟管段高亮", + value: "burst_location_simulation_pipe_highlight", + }, + }); + map.addLayer(layer); + highlightLayerRef.current = layer; + + return () => { + highlightLayerRef.current = null; + map.removeLayer(layer); + }; + }, [map]); + + useEffect(() => { + const source = highlightLayerRef.current?.getSource(); + if (!source) return; + source.clear(); + highlightFeatures.forEach((feature) => source.addFeature(feature)); + }, [highlightFeatures]); + + const locatePipes = async (pipeIds: string[]) => { + const uniquePipeIds = Array.from(new Set(pipeIds.filter(Boolean))); + if (!uniquePipeIds.length || !map) return; + + try { + let features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes_mat"); + if (features.length === 0) { + features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes"); + } + if (features.length === 0) return; + + setHighlightFeatures(features); + const geojsonFormat = new GeoJSON(); + const geojsonFeatures = features.map((feature) => + geojsonFormat.writeFeatureObject(feature), + ); + // @ts-ignore turf typing with ol geojson objects + const extent = bbox(featureCollection(geojsonFeatures)); + map.getView().fit(extent, { + maxZoom: 19, + duration: 1000, + padding: [100, 100, 100, 100], + }); + } catch (error) { + console.error("Locate failed", error); + } + }; + + const getSimulationBurstIds = (payload?: BurstLocationResult) => { + const directIds = payload?.simulation_scheme?.burst_ids ?? []; + if (directIds.length > 0) return directIds; + const simulationSchemeName = payload?.simulation_scheme?.name; + return simulationSchemeName + ? simulationBurstIdsByName[simulationSchemeName] ?? [] + : []; + }; + + const enrichResultWithSimulationBurstIds = (payload: BurstLocationResult) => { + const simulationBurstIds = getSimulationBurstIds(payload); + if (simulationBurstIds.length === 0) return payload; + return { + ...payload, + simulation_scheme: { + ...payload.simulation_scheme, + burst_ids: simulationBurstIds, + }, + }; + }; + const buildDisplayResult = ( scheme: Pick<BurstSchemeRecord, "scheme_name" | "username" | "create_time">, detail?: BurstLocationSchemeDetail, @@ -125,9 +229,30 @@ const SchemeQuery: React.FC<Props> = ({ params.query_date = queryDate.startOf("day").toISOString(); } - const response = await api.get(url, { params }); + const [response, simulationResponse] = await Promise.all([ + api.get(url, { params }), + api.get(`${config.BACKEND_URL}/api/v1/schemes`, { + params: { network: NETWORK_NAME }, + }), + ]); const nextSchemes = response.data as BurstSchemeRecord[]; - setSchemes(nextSchemes); + const nextSimulationBurstIdsByName = Object.fromEntries( + (simulationResponse.data as BurstSimulationSchemeItem[]) + .filter((scheme) => scheme.scheme_type === "burst_analysis") + .map((scheme) => [ + scheme.scheme_name, + normalizeBurstIds(scheme.scheme_detail?.burst_ID), + ]), + ); + setQueryField("simulationBurstIdsByName", nextSimulationBurstIdsByName); + setSchemes( + nextSchemes.map((scheme) => + enrichSchemeWithSimulationBurstIds( + scheme, + nextSimulationBurstIdsByName, + ), + ), + ); open?.({ type: "success", message: "查询成功", @@ -167,7 +292,7 @@ const SchemeQuery: React.FC<Props> = ({ if (!normalizedResult) { throw new Error("方案详情缺少定位结果数据"); } - onViewResult(normalizedResult); + onViewResult(enrichResultWithSimulationBurstIds(normalizedResult)); open?.({ type: "success", message: "方案加载成功", @@ -264,6 +389,7 @@ const SchemeQuery: React.FC<Props> = ({ const summary = scheme.scheme_detail?.result_summary; const payload = scheme.scheme_detail?.result_payload; const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-"; + const simulationBurstIds = getSimulationBurstIds(payload); const leakage = payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage; @@ -340,6 +466,52 @@ const SchemeQuery: React.FC<Props> = ({ {locatedPipe} </Typography> </Box> + {simulationBurstIds.length > 0 ? ( + <Box className="grid grid-cols-[78px_1fr] items-start gap-x-2"> + <Typography variant="caption" className="mt-1 text-gray-600"> + 模拟管段: + </Typography> + <Box className="flex flex-wrap gap-1"> + {simulationBurstIds.map((pipeId) => ( + <Link + key={pipeId} + component="button" + variant="caption" + onClick={() => locatePipes([pipeId])} + title={pipeId} + sx={{ + maxWidth: 132, + color: "#7c3aed", + fontSize: "0.75rem", + fontWeight: 700, + lineHeight: "22px", + cursor: "pointer", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + textDecoration: "underline", + textUnderlineOffset: "2px", + "&:hover": { + color: "#5b21b6", + }, + }} + > + {pipeId} + </Link> + ))} + <Tooltip title="定位全部模拟管段"> + <IconButton + size="small" + color="secondary" + onClick={() => locatePipes(simulationBurstIds)} + className="h-6 w-6 p-0" + > + <LocationOnIcon fontSize="small" /> + </IconButton> + </Tooltip> + </Box> + </Box> + ) : null} <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> <Typography variant="caption" className="text-gray-600"> 漏损量: @@ -383,3 +555,46 @@ const SchemeQuery: React.FC<Props> = ({ }; export default SchemeQuery; + +interface BurstSimulationSchemeItem { + scheme_name: string; + scheme_type: string; + scheme_detail?: { + burst_ID?: string[] | string; + }; +} + +const normalizeBurstIds = (value: string[] | string | undefined) => { + if (!value) return []; + const values = Array.isArray(value) ? value : [value]; + return Array.from( + new Set(values.map((item) => String(item).trim()).filter(Boolean)), + ); +}; + +const enrichSchemeWithSimulationBurstIds = ( + scheme: BurstSchemeRecord, + simulationBurstIdsByName: Record<string, string[]>, +) => { + const payload = scheme.scheme_detail?.result_payload; + const simulationSchemeName = payload?.simulation_scheme?.name; + const simulationBurstIds = simulationSchemeName + ? simulationBurstIdsByName[simulationSchemeName] ?? [] + : []; + if (!payload || simulationBurstIds.length === 0) return scheme; + if (payload.simulation_scheme?.burst_ids?.length) return scheme; + + return { + ...scheme, + scheme_detail: { + ...scheme.scheme_detail, + result_payload: { + ...payload, + simulation_scheme: { + ...payload.simulation_scheme, + burst_ids: simulationBurstIds, + }, + }, + }, + }; +}; diff --git a/src/components/olmap/BurstLocation/types.ts b/src/components/olmap/BurstLocation/types.ts index 0500a36..498e73a 100644 --- a/src/components/olmap/BurstLocation/types.ts +++ b/src/components/olmap/BurstLocation/types.ts @@ -37,6 +37,7 @@ export interface BurstLocationResult { simulation_scheme?: { name?: string; type?: string; + burst_ids?: string[]; }; } -- 2.54.0 From eb8950c89a921d754e1c1cfa7026a65227e7095e Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 17:48:16 +0800 Subject: [PATCH 224/281] feat(map): add valve setting editor --- .../olmap/core/Controls/PropertyPanel.tsx | 204 ++++++++++- .../olmap/core/Controls/Toolbar.tsx | 345 +++++++++++++++++- .../core/Controls/toolbarFeatureHelpers.ts | 113 +++++- 3 files changed, 654 insertions(+), 8 deletions(-) diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index ea49f1b..b69ef3b 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -1,9 +1,19 @@ "use client"; -import React, { useRef } from "react"; +import React, { useRef, useState } from "react"; import Draggable from "react-draggable"; import { Close } from "@mui/icons-material"; -import { IconButton, Tooltip } from "@mui/material"; +import { + Button, + CircularProgress, + FormControl, + IconButton, + MenuItem, + Select, + TextField, + Tooltip, +} from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material/Select"; interface BaseProperty { label: string; @@ -20,7 +30,29 @@ interface TableProperty { rows: (string | number)[][]; // 每行的数据 } -type PropertyItem = BaseProperty | TableProperty; +interface SelectProperty { + type: "select"; + label: string; + value: string; + options: { label: string; value: string }[]; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + onSave: (value: string) => Promise<void>; +} + +interface TextProperty { + type: "text"; + label: string; + value: string; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + helperText?: string; + onSave: (value: string) => Promise<void>; +} + +type PropertyItem = BaseProperty | TableProperty | SelectProperty | TextProperty; interface PropertyPanelProps { id?: string; @@ -36,6 +68,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ onClose, }) => { const draggableRef = useRef<HTMLDivElement>(null); + const [draftValues, setDraftValues] = useState<Record<string, string>>({}); const headerActionSx = { color: "common.white", backgroundColor: "rgba(255,255,255,0.08)", @@ -56,6 +89,26 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"]; + const handleSelectChange = ( + key: string, + event: SelectChangeEvent, + ) => { + setDraftValues((prev) => ({ + ...prev, + [key]: event.target.value, + })); + }; + + const handleTextChange = ( + key: string, + event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, + ) => { + setDraftValues((prev) => ({ + ...prev, + [key]: event.target.value, + })); + }; + // 统计属性数量(表格型按行数计入) const totalProps = id ? 2 + @@ -200,6 +253,151 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ ); } + if ("type" in property && property.type === "select") { + const selected = property as SelectProperty; + const draftKey = `${id ?? "unknown"}:${selected.label}`; + const draftValue = draftValues[draftKey] ?? selected.value ?? ""; + const hasChanged = draftValue !== selected.value; + const getOptionLabel = (value: string) => + selected.options.find((option) => option.value === value) + ?.label ?? value; + const canSave = + hasChanged && + draftValue !== "" && + !selected.disabled && + !selected.saving; + + return ( + <div + key={`select-${index}`} + className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100" + > + <div className="flex items-center justify-between gap-3"> + <span className="font-medium text-xs uppercase tracking-wide text-gray-600"> + {selected.label} + </span> + <div className="flex items-center justify-end gap-2 flex-1"> + <FormControl size="small" sx={{ minWidth: 118 }}> + <Select + value={draftValue} + displayEmpty + disabled={selected.disabled || selected.saving} + onChange={(event) => + handleSelectChange(draftKey, event) + } + renderValue={(value) => + value + ? getOptionLabel(String(value)) + : selected.placeholder || "-" + } + sx={{ + height: 32, + fontSize: 13, + backgroundColor: "white", + }} + > + <MenuItem value="" disabled> + {selected.placeholder || "未设置"} + </MenuItem> + {selected.options.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + </FormControl> + <Button + size="small" + variant="contained" + disabled={!canSave} + onClick={() => selected.onSave(draftValue)} + sx={{ + minWidth: 58, + height: 32, + fontSize: 13, + boxShadow: "none", + }} + > + {selected.saving ? ( + <CircularProgress size={16} color="inherit" /> + ) : ( + "保存" + )} + </Button> + </div> + </div> + </div> + ); + } + + if ("type" in property && property.type === "text") { + const editable = property as TextProperty; + const draftKey = `${id ?? "unknown"}:${editable.label}`; + const draftValue = draftValues[draftKey] ?? editable.value ?? ""; + const hasChanged = draftValue !== editable.value; + const canSave = + hasChanged && + !editable.disabled && + !editable.saving; + + return ( + <div + key={`text-${index}`} + className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100" + > + <div className="flex items-start justify-between gap-3"> + <span className="font-medium text-xs uppercase tracking-wide text-gray-600 pt-2"> + {editable.label} + </span> + <div className="flex flex-col items-end gap-1 flex-1"> + <div className="flex items-center justify-end gap-2 w-full"> + <TextField + size="small" + value={draftValue} + placeholder={editable.placeholder} + disabled={editable.disabled || editable.saving} + onChange={(event) => + handleTextChange(draftKey, event) + } + sx={{ + width: 150, + "& .MuiInputBase-input": { + height: 15, + fontSize: 13, + }, + backgroundColor: "white", + }} + /> + <Button + size="small" + variant="contained" + disabled={!canSave} + onClick={() => editable.onSave(draftValue)} + sx={{ + minWidth: 58, + height: 32, + fontSize: 13, + boxShadow: "none", + }} + > + {editable.saving ? ( + <CircularProgress size={16} color="inherit" /> + ) : ( + "保存" + )} + </Button> + </div> + {editable.helperText && ( + <span className="text-xs text-gray-500 text-right"> + {editable.helperText} + </span> + )} + </div> + </div> + </div> + ); + } + // 普通属性 const base = property as BaseProperty; const isImportant = isImportantKeys.includes(base.label); diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 772123d..f6e6387 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -26,7 +26,8 @@ import { import { useToolbarChatActions } from "./useToolbarChatActions"; import { useStyleEditor } from "./useStyleEditor"; -import { config } from "@/config/config"; +import { config, NETWORK_NAME } from "@/config/config"; +import { useProject } from "@/contexts/ProjectContext"; import { apiFetch } from "@/lib/apiFetch"; // 添加接口定义隐藏按钮的props @@ -37,6 +38,52 @@ interface ToolbarProps { HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板 enableCompare?: boolean; } + +type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE"; +type ValveProperties = { + vType: string | null; + setting: string | null; +}; + +const isValveLayer = (layerId: string | undefined) => + layerId === "geo_valves_mat" || layerId === "geo_valves"; + +const isLinkStatus = (value: unknown): value is LinkStatus => + value === "OPEN" || value === "CLOSED" || value === "ACTIVE"; + +const normalizeValveSetting = (value: unknown): string | null => { + if (value === undefined || value === null) return null; + return String(value); +}; + +const validateValveSetting = ( + valveType: string | null, + value: string, +): string | null => { + const normalizedType = valveType?.toUpperCase(); + const trimmedValue = value.trim(); + const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]); + + if (normalizedType === "GPV") { + return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。"; + } + + if (numericTypes.has(normalizedType ?? "")) { + if (!trimmedValue) { + return "阀门设置值必须是 0 或正数。"; + } + + const numericValue = Number(trimmedValue); + if (!Number.isFinite(numericValue) || numericValue < 0) { + return "阀门设置值必须是有限数字,且大于或等于 0。"; + } + + return null; + } + + return trimmedValue ? null : "阀门设置值不能为空。"; +}; + const Toolbar: React.FC<ToolbarProps> = ({ hiddenButtons, queryType, @@ -46,6 +93,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ }) => { const map = useMap(); const data = useData(); + const project = useProject(); const { open } = useNotification(); const [activeTools, setActiveTools] = useState<string[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); @@ -58,6 +106,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ const currentTime = data?.currentTime; const selectedDate = data?.selectedDate; const schemeName = data?.schemeName; + const networkName = project?.networkName || NETWORK_NAME; const isCompareMode = data?.isCompareMode ?? false; const toggleCompareMode = data?.toggleCompareMode; const canToggleCompare = Boolean( @@ -325,6 +374,261 @@ const Toolbar: React.FC<ToolbarProps> = ({ const [computedProperties, setComputedProperties] = useState< Record<string, any> >({}); + const [valveStatus, setValveStatus] = useState<LinkStatus | null>(null); + const [isValveStatusLoading, setIsValveStatusLoading] = useState(false); + const [isValveStatusSaving, setIsValveStatusSaving] = useState(false); + const [valveProperties, setValveProperties] = useState<ValveProperties>({ + vType: null, + setting: null, + }); + const [isValvePropertiesLoading, setIsValvePropertiesLoading] = + useState(false); + const [isValveSettingSaving, setIsValveSettingSaving] = useState(false); + + const selectedFeature = highlightFeatures[0]; + const selectedFeatureLayer = selectedFeature + ?.getId() + ?.toString() + .split(".")[0]; + const selectedFeatureId = selectedFeature?.getProperties?.().id; + const selectedFeatureValveType = selectedFeature?.getProperties?.().v_type; + const selectedValveType = + valveProperties.vType ?? + (selectedFeatureValveType ? String(selectedFeatureValveType) : null); + const selectedValveId = + showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId + ? String(selectedFeatureId) + : null; + + useEffect(() => { + if (!selectedValveId) { + setValveStatus(null); + setIsValveStatusLoading(false); + return; + } + + let cancelled = false; + + const queryValveStatus = async () => { + setIsValveStatusLoading(true); + setValveStatus(null); + + try { + const params = new URLSearchParams({ + network: networkName, + link: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/getstatus/?${params.toString()}`, + ); + + if (!response.ok) { + throw new Error(`getstatus failed: ${response.status}`); + } + + const data = await response.json(); + if (!cancelled) { + setValveStatus(isLinkStatus(data?.status) ? data.status : null); + } + } catch (error) { + console.error("Error querying valve status:", error); + if (!cancelled) { + setValveStatus(null); + open?.({ + type: "error", + message: "读取阀门开关状态失败。", + }); + } + } finally { + if (!cancelled) { + setIsValveStatusLoading(false); + } + } + }; + + queryValveStatus(); + + return () => { + cancelled = true; + }; + }, [networkName, open, selectedValveId]); + + useEffect(() => { + if (!selectedValveId) { + setValveProperties({ vType: null, setting: null }); + setIsValvePropertiesLoading(false); + return; + } + + let cancelled = false; + + const queryValveProperties = async () => { + setIsValvePropertiesLoading(true); + setValveProperties({ vType: null, setting: null }); + + try { + const params = new URLSearchParams({ + network: networkName, + valve: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/getvalveproperties/?${params.toString()}`, + ); + + if (!response.ok) { + throw new Error(`getvalveproperties failed: ${response.status}`); + } + + const data = await response.json(); + if (!cancelled) { + setValveProperties({ + vType: data?.v_type ? String(data.v_type) : null, + setting: normalizeValveSetting(data?.setting), + }); + } + } catch (error) { + console.error("Error querying valve properties:", error); + if (!cancelled) { + setValveProperties({ vType: null, setting: null }); + open?.({ + type: "error", + message: "读取阀门设置值失败。", + }); + } + } finally { + if (!cancelled) { + setIsValvePropertiesLoading(false); + } + } + }; + + queryValveProperties(); + + return () => { + cancelled = true; + }; + }, [networkName, open, selectedValveId]); + + const handleValveStatusSave = useCallback( + async (nextStatus: string) => { + if (!selectedValveId || !isLinkStatus(nextStatus)) { + return; + } + + setIsValveStatusSaving(true); + try { + const params = new URLSearchParams({ + network: networkName, + link: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/setstatus/?${params.toString()}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status: nextStatus }), + }, + ); + + if (!response.ok) { + throw new Error(`setstatus failed: ${response.status}`); + } + + setValveStatus(nextStatus); + open?.({ + type: "success", + message: "阀门开关状态已更新。", + }); + } catch (error) { + console.error("Error updating valve status:", error); + open?.({ + type: "error", + message: "阀门开关状态更新失败。", + }); + } finally { + setIsValveStatusSaving(false); + } + }, + [networkName, open, selectedValveId], + ); + + const handleValveSettingSave = useCallback( + async (nextSetting: string) => { + if (!selectedValveId) { + return; + } + + if (valveStatus === "OPEN" || valveStatus === "CLOSED") { + open?.({ + type: "error", + message: "开启或关闭状态下不能编辑阀门设置值。", + }); + return; + } + + const validationMessage = validateValveSetting( + selectedValveType, + nextSetting, + ); + if (validationMessage) { + open?.({ + type: "error", + message: validationMessage, + }); + return; + } + + const trimmedSetting = nextSetting.trim(); + setIsValveSettingSaving(true); + try { + const params = new URLSearchParams({ + network: networkName, + valve: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/setvalveproperties/?${params.toString()}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ setting: trimmedSetting }), + }, + ); + + if (!response.ok) { + throw new Error(`setvalveproperties failed: ${response.status}`); + } + + setValveProperties((prev) => ({ + ...prev, + setting: trimmedSetting, + })); + open?.({ + type: "success", + message: "阀门设置值已更新。", + }); + } catch (error) { + console.error("Error updating valve setting:", error); + open?.({ + type: "error", + message: "阀门设置值更新失败。", + }); + } finally { + setIsValveSettingSaving(false); + } + }, + [ + networkName, + open, + selectedValveId, + selectedValveType, + valveStatus, + ], + ); + // 添加 useEffect 来查询计算属性 useEffect(() => { if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) { @@ -387,8 +691,43 @@ const Toolbar: React.FC<ToolbarProps> = ({ }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]); const propertyPanelData = useMemo( - () => buildFeatureProperties(highlightFeatures[0], computedProperties), - [highlightFeatures, computedProperties], + () => + buildFeatureProperties( + selectedFeature, + computedProperties, + selectedValveId + ? { + value: valveStatus, + loading: isValveStatusLoading, + saving: isValveStatusSaving, + onSave: handleValveStatusSave, + } + : undefined, + selectedValveId + ? { + value: valveProperties.setting, + vType: selectedValveType, + loading: isValvePropertiesLoading, + saving: isValveSettingSaving, + status: valveStatus, + onSave: handleValveSettingSave, + } + : undefined, + ), + [ + selectedFeature, + computedProperties, + selectedValveId, + valveStatus, + valveProperties, + selectedValveType, + isValveStatusLoading, + isValveStatusSaving, + isValvePropertiesLoading, + isValveSettingSaving, + handleValveStatusSave, + handleValveSettingSave, + ], ); if (!data) { diff --git a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts index dc1019b..577f48f 100644 --- a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts +++ b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts @@ -16,7 +16,33 @@ type ToolbarTableProperty = { rows: (string | number)[][]; }; -export type ToolbarPropertyItem = ToolbarBaseProperty | ToolbarTableProperty; +type ToolbarSelectProperty = { + type: "select"; + label: string; + value: string; + options: { label: string; value: string }[]; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + onSave: (value: string) => Promise<void>; +}; + +type ToolbarTextProperty = { + type: "text"; + label: string; + value: string; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + helperText?: string; + onSave: (value: string) => Promise<void>; +}; + +export type ToolbarPropertyItem = + | ToolbarBaseProperty + | ToolbarTableProperty + | ToolbarSelectProperty + | ToolbarTextProperty; export type ToolbarPropertyPanelData = { id?: string; @@ -24,6 +50,46 @@ export type ToolbarPropertyPanelData = { properties?: ToolbarPropertyItem[]; }; +export type ValveStatusPropertyOptions = { + value: string | null; + loading?: boolean; + saving?: boolean; + onSave: (value: string) => Promise<void>; +}; + +export type ValveSettingPropertyOptions = { + value: string | null; + vType: string | null; + loading?: boolean; + saving?: boolean; + status?: string | null; + onSave: (value: string) => Promise<void>; +}; + +const getValveSettingHelperText = ( + vType: string | null, + status?: string | null, +) => { + if (status === "OPEN" || status === "CLOSED") { + return "开启/关闭状态下 EPANET 会忽略阀门设置值"; + } + + switch (vType?.toUpperCase()) { + case "PRV": + case "PSV": + case "PBV": + return "压力设置值,需为 0 或正数"; + case "FCV": + return "流量设置值,需为 0 或正数"; + case "TCV": + return "损失系数,需为 0 或正数"; + case "GPV": + return "水头损失曲线 ID"; + default: + return "阀门类型相关设置值"; + } +}; + const getFeatureHistoryType = (feature: Feature): string | null => { const layerId = feature.getId()?.toString().split(".")[0] || ""; if (layerId.includes("pipe")) return "pipe"; @@ -56,6 +122,8 @@ export const inferHistoryFeatureInfos = ( export const buildFeatureProperties = ( highlightFeature: Feature | undefined, computedProperties: Record<string, any>, + valveStatus?: ValveStatusPropertyOptions, + valveSetting?: ValveSettingPropertyOptions, ): ToolbarPropertyPanelData => { if (!highlightFeature) return {}; @@ -267,6 +335,12 @@ export const buildFeatureProperties = ( } if (layer === "geo_valves_mat" || layer === "geo_valves") { + const valveType = valveSetting?.vType ?? properties.v_type; + const isValveSettingDisabled = + valveSetting?.loading || + valveSetting?.status === "OPEN" || + valveSetting?.status === "CLOSED"; + return { id: properties.id, type: "阀门", @@ -280,12 +354,47 @@ export const buildFeatureProperties = ( }, { label: "阀门类型", - value: properties.v_type, + value: valveType, }, { label: "局部损失", value: properties.minor_loss?.toFixed?.(2), }, + ...(valveStatus + ? [ + { + type: "select" as const, + label: "开关状态", + value: valveStatus.value ?? "", + options: [ + { label: "开启", value: "OPEN" }, + { label: "关闭", value: "CLOSED" }, + { label: "激活", value: "ACTIVE" }, + ], + placeholder: valveStatus.loading ? "加载中" : "未设置", + disabled: valveStatus.loading, + saving: valveStatus.saving, + onSave: valveStatus.onSave, + }, + ] + : []), + ...(valveSetting + ? [ + { + type: "text" as const, + label: "阀门设置值", + value: valveSetting.value ?? "", + placeholder: valveSetting.loading ? "加载中" : "未设置", + disabled: isValveSettingDisabled, + saving: valveSetting.saving, + helperText: getValveSettingHelperText( + valveType, + valveSetting.status, + ), + onSave: valveSetting.onSave, + }, + ] + : []), ], }; } -- 2.54.0 From 3d7b5946829b159ba8a8c65e735cec17a256afb6 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 17:55:58 +0800 Subject: [PATCH 225/281] style(map): align valve setting input --- src/components/olmap/core/Controls/PropertyPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index b69ef3b..ab8b923 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -360,7 +360,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ handleTextChange(draftKey, event) } sx={{ - width: 150, + width: 118, "& .MuiInputBase-input": { height: 15, fontSize: 13, -- 2.54.0 From fddb0ceb34c4c88e201fca9873f2b6e082cde2ef Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 16 Jul 2026 18:02:29 +0800 Subject: [PATCH 226/281] fix(map): restrict waterflow layer to flow --- .../olmap/core/Controls/useStyleEditor.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index efdd9b4..88183f7 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -60,6 +60,7 @@ export const useStyleEditor = ({ const setShowPipeId = data?.setShowPipeId; const setContourLayerAvailable = data?.setContourLayerAvailable; const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable; + const setShowWaterflowLayer = data?.setShowWaterflowLayer; const setJunctionText = data?.setJunctionText; const setPipeText = data?.setPipeText; const setContours = data?.setContours; @@ -557,11 +558,15 @@ export const useStyleEditor = ({ } if (layerId === "pipes") { + const isFlowProperty = property === "flow"; setPipeText?.(property); setShowPipeTextLayer?.(styleConfig.showLabels); setShowPipeId?.(styleConfig.showId); setApplyPipeStyle(true); - setWaterflowLayerAvailable?.(true); + setWaterflowLayerAvailable?.(isFlowProperty); + if (!isFlowProperty) { + setShowWaterflowLayer?.(false); + } saveLayerStyle(layerId); open?.({ type: "success", @@ -581,6 +586,7 @@ export const useStyleEditor = ({ setShowJunctionTextLayer, setShowPipeId, setShowPipeTextLayer, + setShowWaterflowLayer, setWaterflowLayerAvailable, styleConfig, ]); @@ -744,10 +750,14 @@ export const useStyleEditor = ({ setContourLayerAvailable?.(nextStyleConfig.property === "pressure"); setApplyJunctionStyle(true); } else { + const isFlowProperty = nextStyleConfig.property === "flow"; setPipeText?.(nextStyleConfig.property); setShowPipeTextLayer?.(nextStyleConfig.showLabels); setShowPipeId?.(nextStyleConfig.showId); - setWaterflowLayerAvailable?.(true); + setWaterflowLayerAvailable?.(isFlowProperty); + if (!isFlowProperty) { + setShowWaterflowLayer?.(false); + } setApplyPipeStyle(true); } @@ -769,6 +779,7 @@ export const useStyleEditor = ({ setShowJunctionTextLayer, setShowPipeId, setShowPipeTextLayer, + setShowWaterflowLayer, setWaterflowLayerAvailable, upsertLayerStyleState, ] @@ -991,9 +1002,12 @@ export const useStyleEditor = ({ setContourLayerAvailable?.( defaultJunctionStyleState.styleConfig.property === "pressure" ); - setWaterflowLayerAvailable?.( - defaultPipeStyleState.styleConfig.property === "flow" - ); + const isDefaultPipeFlow = + defaultPipeStyleState.styleConfig.property === "flow"; + setWaterflowLayerAvailable?.(isDefaultPipeFlow); + if (!isDefaultPipeFlow) { + setShowWaterflowLayer?.(false); + } setApplyJunctionStyle(true); setApplyPipeStyle(true); @@ -1014,6 +1028,7 @@ export const useStyleEditor = ({ setShowJunctionTextLayer, setShowPipeId, setShowPipeTextLayer, + setShowWaterflowLayer, setWaterflowLayerAvailable, ]); -- 2.54.0 From d8ee2e1f0cae2b1220207d57a84356727a8c8024 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 10:01:06 +0800 Subject: [PATCH 227/281] fix(burst): show actual pipe diameters --- .../olmap/BurstSimulation/SchemeQuery.tsx | 90 ++++++++++++++++++- .../schemePipeDiameters.test.ts | 20 +++++ .../BurstSimulation/schemePipeDiameters.ts | 27 ++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/components/olmap/BurstSimulation/schemePipeDiameters.test.ts create mode 100644 src/components/olmap/BurstSimulation/schemePipeDiameters.ts diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index a92d6b1..67082b0 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -51,6 +51,10 @@ import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; import Timeline from "@components/olmap/core/Controls/Timeline"; import { SchemaItem, SchemeRecord } from "./types"; +import { + getPipeDiameterDisplay, + type PipeDiameterMap, +} from "./schemePipeDiameters"; interface SchemeQueryProps { schemes?: SchemeRecord[]; @@ -109,6 +113,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素 + const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState< + Record<number, PipeDiameterMap> + >({}); + const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState< + Record<number, boolean> + >({}); const { open } = useNotification(); @@ -209,6 +219,80 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ } }; + useEffect(() => { + if (expandedId === null || pipeDiametersByScheme[expandedId]) { + return; + } + + const scheme = filteredSchemes.find((scheme) => scheme.id === expandedId); + const pipeIds = scheme?.schemeDetail?.burst_ID ?? []; + if (pipeIds.length === 0) { + return; + } + + let cancelled = false; + setLoadingDiameterByScheme((previous) => ({ + ...previous, + [expandedId]: true, + })); + + const loadPipeDiameters = async () => { + let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat"); + const foundPipeIds = new Set( + features.map((feature) => String(feature.getProperties().id)), + ); + const missingPipeIds = pipeIds.filter( + (pipeId) => !foundPipeIds.has(pipeId), + ); + + if (missingPipeIds.length > 0) { + const fallbackFeatures = await queryFeaturesByIds( + missingPipeIds, + "geo_pipes", + ); + features = [...features, ...fallbackFeatures]; + } + + const nextDiameters: PipeDiameterMap = Object.fromEntries( + pipeIds.map((pipeId) => [pipeId, null]), + ); + + features.forEach((feature) => { + const properties = feature.getProperties(); + const pipeId = String(properties.id); + const diameter = Number(properties.diameter); + nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null; + }); + + if (cancelled) { + return; + } + + setPipeDiametersByScheme((previous) => ({ + ...previous, + [expandedId]: nextDiameters, + })); + setLoadingDiameterByScheme((previous) => ({ + ...previous, + [expandedId]: false, + })); + }; + + loadPipeDiameters().catch((error) => { + console.error("查询管径失败:", error); + if (!cancelled) { + setLoadingDiameterByScheme((previous) => ({ + ...previous, + [expandedId]: false, + })); + } + }); + + return () => { + cancelled = true; + }; + }, [expandedId, filteredSchemes, pipeDiametersByScheme]); + // 内部的方案查询函数 const handleViewDetails = (id: number) => { const scheme = filteredSchemes.find((s) => s.id === id); @@ -568,7 +652,11 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ variant="caption" className="font-medium text-gray-900" > - 560 mm + {getPipeDiameterDisplay( + scheme.schemeDetail?.burst_ID, + pipeDiametersByScheme[scheme.id], + !!loadingDiameterByScheme[scheme.id], + )} </Typography> </Box> <Box className="flex items-center gap-2"> diff --git a/src/components/olmap/BurstSimulation/schemePipeDiameters.test.ts b/src/components/olmap/BurstSimulation/schemePipeDiameters.test.ts new file mode 100644 index 0000000..7ee83bd --- /dev/null +++ b/src/components/olmap/BurstSimulation/schemePipeDiameters.test.ts @@ -0,0 +1,20 @@ +import { getPipeDiameterDisplay } from "./schemePipeDiameters"; + +describe("getPipeDiameterDisplay", () => { + it("shows the actual diameter for one burst pipe", () => { + expect(getPipeDiameterDisplay(["P-1"], { "P-1": 315 })).toBe("315 mm"); + }); + + it("shows pipe IDs with diameters for multiple burst pipes", () => { + expect( + getPipeDiameterDisplay(["P-1", "P-2"], { + "P-1": 315, + "P-2": 800, + }), + ).toBe("P-1: 315 mm;P-2: 800 mm"); + }); + + it("does not fall back to a fixed diameter when a diameter is missing", () => { + expect(getPipeDiameterDisplay(["P-1"], {})).toBe("N/A"); + }); +}); diff --git a/src/components/olmap/BurstSimulation/schemePipeDiameters.ts b/src/components/olmap/BurstSimulation/schemePipeDiameters.ts new file mode 100644 index 0000000..69d2af6 --- /dev/null +++ b/src/components/olmap/BurstSimulation/schemePipeDiameters.ts @@ -0,0 +1,27 @@ +export type PipeDiameterMap = Record<string, number | null | undefined>; + +export const getPipeDiameterDisplay = ( + pipeIds: string[] | undefined, + diameters: PipeDiameterMap | undefined, + loading = false, +): string => { + if (loading) { + return "查询中..."; + } + + if (!pipeIds?.length) { + return "N/A"; + } + + const values = pipeIds.map((pipeId) => { + const diameter = diameters?.[pipeId]; + const displayValue = + typeof diameter === "number" && Number.isFinite(diameter) + ? `${diameter} mm` + : "N/A"; + + return pipeIds.length === 1 ? displayValue : `${pipeId}: ${displayValue}`; + }); + + return values.join(";"); +}; -- 2.54.0 From d986e563a616e1390272bbf56974404b8922ee36 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 10:13:50 +0800 Subject: [PATCH 228/281] fix(map): convert actual demand display units --- .../olmap/core/Controls/useStyleEditor.ts | 36 ++++++++++++++----- src/components/olmap/core/MapComponent.tsx | 7 ++-- src/utils/units.test.ts | 19 ++++++++++ src/utils/units.ts | 4 +++ 4 files changed, 54 insertions(+), 12 deletions(-) create mode 100644 src/utils/units.test.ts diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index 88183f7..65c4506 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -32,9 +32,23 @@ import { } from "./styleEditorTypes"; import { LegendStyleConfig } from "./StyleLegend"; import { calculateClassification } from "@utils/breaksClassification"; +import { isLpsFlowProperty, toM3h } from "@utils/units"; const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5]; +const normalizeComputedStyleValue = (property: string, value: unknown) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return Number.NaN; + } + + const displayValue = isLpsFlowProperty(property) + ? toM3h(numericValue, "lps") + : numericValue; + + return property === "flow" ? Math.abs(displayValue) : displayValue; +}; + export const useStyleEditor = ({ layerStyleStates, setLayerStyleStates, @@ -337,12 +351,20 @@ export const useStyleEditor = ({ layerType === "junctions" ? isElevation && elevationRange ? [elevationRange[0], elevationRange[1]] - : currentJunctionCalData?.map((item: any) => item.value) || [] + : currentJunctionCalData + ?.map((item: any) => + normalizeComputedStyleValue(effectiveStyleConfig.property, item.value) + ) + .filter(Number.isFinite) || [] : isDiameter && diameterRange ? [diameterRange[0], diameterRange[1]] : isUnitHeadloss ? [UNIT_HEADLOSS_RANGE[0], UNIT_HEADLOSS_RANGE[1]] - : currentPipeCalData?.map((item: any) => item.value) || []; + : currentPipeCalData + ?.map((item: any) => + normalizeComputedStyleValue(effectiveStyleConfig.property, item.value) + ) + .filter(Number.isFinite) || []; const canApply = layerType === "junctions" @@ -416,7 +438,7 @@ export const useStyleEditor = ({ const dataMap = new Map<string, number>(); records.forEach((record: any) => { - dataMap.set(record.ID, record.value || 0); + dataMap.set(record.ID, normalizeComputedStyleValue(property, record.value || 0)); }); vectorTileSources.forEach((vectorTileSource) => { @@ -434,8 +456,7 @@ export const useStyleEditor = ({ return; } - renderFeature.properties_[property] = - property === "flow" ? Math.abs(value) : value; + renderFeature.properties_[property] = value; }); }); }); @@ -457,7 +478,7 @@ export const useStyleEditor = ({ const dataMap = new Map<string, number>(); records.forEach((record: any) => { - dataMap.set(record.ID, record.value || 0); + dataMap.set(record.ID, normalizeComputedStyleValue(property, record.value || 0)); }); const listener = (event: any) => { @@ -478,8 +499,7 @@ export const useStyleEditor = ({ return; } - renderFeature.properties_[property] = - property === "flow" ? Math.abs(value) : value; + renderFeature.properties_[property] = value; }); } catch (error) { console.error("Error processing tile load event:", error); diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 6829155..51a6786 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -24,7 +24,7 @@ import { TextLayer } from "@deck.gl/layers"; import { TripsLayer } from "@deck.gl/geo-layers"; import { CollisionFilterExtension } from "@deck.gl/extensions"; import { ContourLayer } from "deck.gl"; -import { toM3h } from "@utils/units"; +import { isLpsFlowProperty, toM3h } from "@utils/units"; import { usePathname } from "next/navigation"; import { cleanupTransientMapResources, @@ -204,8 +204,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { return junctionData.map((j) => { const record = nodeMap.get(j.id); let val = record ? record.value : undefined; - // 在这合并时将实际需水量从 LPS 转换为大写表示 - if (val !== undefined && junctionText === "actualdemand") { + if (val !== undefined && isLpsFlowProperty(junctionText)) { val = toM3h(val, "lps"); } return record ? { ...j, [junctionText]: val } : j; @@ -236,7 +235,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { return junctionData.map((j) => { const record = nodeMap.get(j.id); let val = record ? record.value : undefined; - if (val !== undefined && junctionText === "actualdemand") { + if (val !== undefined && isLpsFlowProperty(junctionText)) { val = toM3h(val, "lps"); } return record ? { ...j, [junctionText]: val } : j; diff --git a/src/utils/units.test.ts b/src/utils/units.test.ts new file mode 100644 index 0000000..0659f94 --- /dev/null +++ b/src/utils/units.test.ts @@ -0,0 +1,19 @@ +import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units"; + +describe("flow display units", () => { + it("uses cubic meters per hour as the flow display unit", () => { + expect(FLOW_DISPLAY_UNIT).toBe("m³/h"); + }); + + it("converts L/s values to m³/h", () => { + expect(toM3h(10, "lps")).toBe(36); + expect(toM3h(10, "L/s")).toBe(36); + }); + + it("recognizes computed properties that arrive from the backend in L/s", () => { + expect(isLpsFlowProperty("flow")).toBe(true); + expect(isLpsFlowProperty("actual_demand")).toBe(true); + expect(isLpsFlowProperty("actualdemand")).toBe(true); + expect(isLpsFlowProperty("pressure")).toBe(false); + }); +}); diff --git a/src/utils/units.ts b/src/utils/units.ts index 08dcf7e..f90c721 100644 --- a/src/utils/units.ts +++ b/src/utils/units.ts @@ -1,5 +1,9 @@ export const FLOW_DISPLAY_UNIT = "m³/h"; const M3H_FACTOR = 3600; +const LPS_FLOW_PROPERTIES = new Set(["flow", "actual_demand", "actualdemand"]); + +export const isLpsFlowProperty = (property: string) => + LPS_FLOW_PROPERTIES.has(property); export const toM3h = (value: number, sourceUnit: string = "m³/s") => { if (!Number.isFinite(value)) return Number.NaN; -- 2.54.0 From acf13639efcdcebac0abf215efe1567942ff151e Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 10:21:16 +0800 Subject: [PATCH 229/281] fix(map): allow selecting features under overlays --- src/components/olmap/BurstDetection/DetectionResults.tsx | 1 + src/components/olmap/BurstLocation/LocationResults.tsx | 1 + src/components/olmap/BurstLocation/SchemeQuery.tsx | 1 + src/components/olmap/BurstSimulation/AnalysisParameters.tsx | 1 + src/components/olmap/BurstSimulation/LocationResults.tsx | 1 + src/components/olmap/BurstSimulation/SchemeQuery.tsx | 1 + src/components/olmap/BurstSimulation/ValveIsolation.tsx | 1 + .../olmap/ContaminantSimulation/AnalysisParameters.tsx | 1 + src/components/olmap/ContaminantSimulation/SchemeQuery.tsx | 1 + .../olmap/FlushingAnalysis/AnalysisParameters.tsx | 1 + src/components/olmap/FlushingAnalysis/SchemeQuery.tsx | 1 + .../olmap/MonitoringPlaceOptimization/SchemeQuery.tsx | 1 + src/components/olmap/SCADA/SCADADeviceList.tsx | 1 + src/components/olmap/core/Controls/Toolbar.tsx | 1 + src/utils/mapQueryService.ts | 6 +++++- 15 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx index 8102aaa..af2a583 100644 --- a/src/components/olmap/BurstDetection/DetectionResults.tsx +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -154,6 +154,7 @@ const DetectionResults: React.FC<Props> = ({ properties: { name: "爆管侦测高亮", value: "burst_detection_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index a697b4d..6bf7a8a 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -184,6 +184,7 @@ const LocationResults: React.FC<Props> = ({ result }) => { properties: { name: "爆管定位高亮", value: "burst_location_highlight", + queryable: false, }, }); map.addLayer(layer); diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index c64cd58..ef0cd8b 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -118,6 +118,7 @@ const SchemeQuery: React.FC<Props> = ({ properties: { name: "爆管定位模拟管段高亮", value: "burst_location_simulation_pipe_highlight", + queryable: false, }, }); map.addLayer(layer); diff --git a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx index 419d66e..4a3d3f5 100644 --- a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx @@ -198,6 +198,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ properties: { name: "高亮管道", value: "highlight_pipeline", + queryable: false, }, }); diff --git a/src/components/olmap/BurstSimulation/LocationResults.tsx b/src/components/olmap/BurstSimulation/LocationResults.tsx index d5b3503..9f14001 100644 --- a/src/components/olmap/BurstSimulation/LocationResults.tsx +++ b/src/components/olmap/BurstSimulation/LocationResults.tsx @@ -140,6 +140,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({ properties: { name: "爆管管段高亮", value: "burst_pipe_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index 67082b0..7d6dad8 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -391,6 +391,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ properties: { name: "爆管管段高亮", value: "burst_pipe_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index 11ddd97..93f80d0 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -546,6 +546,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ properties: { name: "阀门节点高亮", value: "valve_node_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index a7f340f..7d03461 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -158,6 +158,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ properties: { name: "污染源节点", value: "contaminant_source_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index 4797660..92f83bd 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -166,6 +166,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ properties: { name: "污染源高亮", value: "contaminant_source_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 67848e4..8fdad66 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -185,6 +185,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ zIndex: 1000, properties: { name: "FlushingHighlight", + queryable: false, }, }); diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index 256b5b3..b6ae215 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -179,6 +179,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ zIndex: 1000, properties: { name: "FlushingQueryResultHighlight", + queryable: false, }, }); diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index cec0973..4cee79d 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -143,6 +143,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ properties: { name: "传感器高亮", value: "sensor_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/SCADA/SCADADeviceList.tsx b/src/components/olmap/SCADA/SCADADeviceList.tsx index f93e2d4..4952435 100644 --- a/src/components/olmap/SCADA/SCADADeviceList.tsx +++ b/src/components/olmap/SCADA/SCADADeviceList.tsx @@ -708,6 +708,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ properties: { name: "SCADA 选中高亮", value: "scada_selected_highlight", + queryable: false, }, }); diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index f6e6387..2596ca1 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -189,6 +189,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ properties: { name: "属性查询高亮图层", // 设置图层名称 value: "info_highlight_layer", + queryable: false, type: "multigeometry", properties: [], }, diff --git a/src/utils/mapQueryService.ts b/src/utils/mapQueryService.ts index 7822b5e..86e3aad 100644 --- a/src/utils/mapQueryService.ts +++ b/src/utils/mapQueryService.ts @@ -58,6 +58,10 @@ const MAP_CONFIG = { bufferUnits: "meters" as const, } as const; +const isQueryableVectorLayer = (layer: unknown): layer is VectorLayer => { + return layer instanceof VectorLayer && layer.get("queryable") !== false; +}; + // ========== 辅助函数 ========== /** @@ -413,7 +417,7 @@ const handleMapClickSelectFeatures = async ( }, { hitTolerance: MAP_CONFIG.hitTolerance, - layerFilter: (layer) => layer instanceof VectorLayer, + layerFilter: isQueryableVectorLayer, } ); -- 2.54.0 From 202f18332f36e93e688d04367997776097f6a50c Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 10:48:58 +0800 Subject: [PATCH 230/281] fix(notification): clarify query and progress feedback --- src/app/RefineContext.tsx | 4 +- .../olmap/BurstSimulation/SchemeQuery.tsx | 6 + .../ContaminantSimulation/SchemeQuery.tsx | 6 + .../olmap/DMALeakDetection/SchemeQuery.tsx | 15 ++ .../olmap/FlushingAnalysis/SchemeQuery.tsx | 6 + .../SchemeQuery.tsx | 6 + .../useAppNotificationProvider.test.tsx | 78 ++++++++++ .../useAppNotificationProvider.tsx | 137 ++++++++++++++++++ 8 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/providers/notification-provider/useAppNotificationProvider.test.tsx create mode 100644 src/providers/notification-provider/useAppNotificationProvider.tsx diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 3b76364..4826171 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { RefineSnackbarProvider, - useNotificationProvider, } from "@refinedev/mui"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; @@ -18,6 +17,7 @@ import { ProjectProvider } from "@/contexts/ProjectContext"; import { useAuthStore } from "@/store/authStore"; import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; +import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; import { LiaNetworkWiredSolid } from "react-icons/lia"; import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; @@ -165,7 +165,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => { <Refine routerProvider={routerProvider} dataProvider={dataProvider} - notificationProvider={useNotificationProvider} + notificationProvider={useAppNotificationProvider} authProvider={authProvider} resources={[ { diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index 7d6dad8..c05b8c5 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -184,6 +184,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ ? "没有找到任何方案" : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index 92f83bd..9fb159d 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -250,6 +250,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ ? "没有找到任何方案" : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index bae1d39..3895d01 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -88,6 +88,21 @@ const SchemeQuery: React.FC<Props> = ({ }); const nextSchemes = response.data as LeakageSchemeRecord[]; setSchemes(nextSchemes); + if (nextSchemes.length === 0) { + open?.({ + type: "error", + message: "查询结果", + description: queryAll + ? "没有找到任何方案" + : `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`, + }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${nextSchemes.length} 条方案记录`, + }); + } } catch (error: any) { open?.({ type: "error", diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index b6ae215..be64f71 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -296,6 +296,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ message: "未找到相关方案", description: "请尝试更改查询条件", }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 4cee79d..4b9db6f 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -214,6 +214,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ ? "没有找到任何方案" : `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`, }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/providers/notification-provider/useAppNotificationProvider.test.tsx b/src/providers/notification-provider/useAppNotificationProvider.test.tsx new file mode 100644 index 0000000..bdba34a --- /dev/null +++ b/src/providers/notification-provider/useAppNotificationProvider.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { useSnackbar } from "@refinedev/mui"; + +import { useAppNotificationProvider } from "./useAppNotificationProvider"; + +jest.mock("@refinedev/mui", () => ({ + useSnackbar: jest.fn(), +})); + +const mockedUseSnackbar = useSnackbar as jest.MockedFunction<typeof useSnackbar>; + +const TestComponent = ({ + cancelMutation, +}: { + cancelMutation?: () => void; +}) => { + const notificationProvider = useAppNotificationProvider(); + + return ( + <button + type="button" + onClick={() => + notificationProvider.open({ + key: "analysis-progress", + type: "progress", + message: "分析中", + undoableTimeout: 3, + cancelMutation, + }) + } + > + Open progress + </button> + ); +}; + +describe("useAppNotificationProvider", () => { + const enqueueSnackbar = jest.fn(); + const closeSnackbar = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockedUseSnackbar.mockReturnValue({ + enqueueSnackbar, + closeSnackbar, + } as unknown as ReturnType<typeof useSnackbar>); + }); + + it("does not render an undo action for progress notifications without cancellation", async () => { + render(<TestComponent />); + + fireEvent.click(screen.getByRole("button", { name: "Open progress" })); + + expect(enqueueSnackbar).toHaveBeenCalledTimes(1); + expect(enqueueSnackbar.mock.calls[0][1]).toMatchObject({ + key: "analysis-progress", + preventDuplicate: true, + autoHideDuration: 3000, + }); + expect(enqueueSnackbar.mock.calls[0][1]).not.toHaveProperty("action"); + }); + + it("keeps undo action for progress notifications with cancellation", async () => { + const cancelMutation = jest.fn(); + render(<TestComponent cancelMutation={cancelMutation} />); + + fireEvent.click(screen.getByRole("button", { name: "Open progress" })); + + const options = enqueueSnackbar.mock.calls[0][1]; + expect(options.action).toEqual(expect.any(Function)); + + const undoAction = render(options.action("progress-key")); + fireEvent.click(within(undoAction.container).getByRole("button")); + + expect(cancelMutation).toHaveBeenCalledTimes(1); + expect(closeSnackbar).toHaveBeenCalledWith("progress-key"); + }); +}); diff --git a/src/providers/notification-provider/useAppNotificationProvider.tsx b/src/providers/notification-provider/useAppNotificationProvider.tsx new file mode 100644 index 0000000..7ac2f06 --- /dev/null +++ b/src/providers/notification-provider/useAppNotificationProvider.tsx @@ -0,0 +1,137 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import type { NotificationProvider } from "@refinedev/core"; +import { useSnackbar } from "@refinedev/mui"; +import UndoOutlined from "@mui/icons-material/UndoOutlined"; +import Box from "@mui/material/Box"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; + +type ProgressNotificationProps = { + undoableTimeout: number; + message: string; +}; + +const ProgressNotification = ({ + undoableTimeout, + message, +}: ProgressNotificationProps) => { + const [progress, setProgress] = useState(100); + const [timeCount, setTimeCount] = useState(undoableTimeout); + + useEffect(() => { + if (undoableTimeout <= 0 || timeCount <= 0) { + return; + } + + const progressStep = 100 / undoableTimeout; + const timer = window.setInterval(() => { + setTimeCount((previous) => Math.max(previous - 1, 0)); + setProgress((previous) => Math.max(previous - progressStep, 0)); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, [timeCount, undoableTimeout]); + + return ( + <> + <Box sx={{ position: "relative", display: "inline-flex" }}> + <CircularProgress + color="inherit" + variant="determinate" + value={progress} + /> + <Box + sx={{ + top: 0, + left: 0, + bottom: 0, + right: 0, + position: "absolute", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <Typography component="div">{timeCount}</Typography> + </Box> + </Box> + <Box + sx={{ + marginLeft: "10px", + maxWidth: { xs: "150px", md: "100%" }, + }} + > + <Typography variant="subtitle2">{message}</Typography> + </Box> + </> + ); +}; + +export const useAppNotificationProvider = (): NotificationProvider => { + const { closeSnackbar, enqueueSnackbar } = useSnackbar(); + + return { + open: ({ + message, + type, + undoableTimeout, + key, + cancelMutation, + description, + }) => { + if (type === "progress") { + const options: NonNullable<Parameters<typeof enqueueSnackbar>[1]> = { + preventDuplicate: true, + key, + autoHideDuration: (undoableTimeout ?? 0) * 1000, + }; + + if (cancelMutation) { + options.action = (snackbarKey) => ( + <IconButton + onClick={() => { + cancelMutation(); + closeSnackbar(snackbarKey); + }} + color="inherit" + > + <UndoOutlined /> + </IconButton> + ); + } + + enqueueSnackbar( + <ProgressNotification + undoableTimeout={undoableTimeout ?? 0} + message={message} + />, + options, + ); + return; + } + + enqueueSnackbar( + <Box> + <Typography variant="subtitle2" component="h6"> + {description} + </Typography> + <Typography variant="caption" component="p"> + {message} + </Typography> + </Box>, + { + key, + variant: type, + }, + ); + }, + close: (key) => { + closeSnackbar(key); + }, + }; +}; -- 2.54.0 From 4adbcc1c4cebda51d151b9826c15234bc01299df Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 11:45:58 +0800 Subject: [PATCH 231/281] fix(dma): defer leakage flow validation --- .../DMALeakDetection/AnalysisParameters.tsx | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index cf3a1d1..c79b8a6 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -74,18 +74,31 @@ const AnalysisParameters: React.FC<Props> = ({ advancedOpen, } = parametersState; const [running, setRunning] = useState(false); + const [qSumInput, setQSumInput] = useState(() => String(qSum)); + React.useEffect(() => { + setQSumInput(String(qSum)); + }, [qSum]); + + const parsedQSum = Number(qSumInput); + const qSumIsValid = + qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 360; const isValid = useMemo(() => { if (!schemeName.trim() || !startTime || !endTime) return false; - return startTime.isBefore(endTime) && qSum >= 360; - }, [schemeName, startTime, endTime, qSum]); + return startTime.isBefore(endTime) && qSumIsValid; + }, [schemeName, startTime, endTime, qSumIsValid]); const handleRun = async () => { if (!isValid || !startTime || !endTime) { - open?.({ type: "error", message: "请完善参数并确认时间范围合法" }); + open?.({ + type: "error", + message: "请完善参数并确认时间范围合法", + description: !qSumIsValid ? `总漏损流量需不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined, + }); return; } + setFormField("qSum", parsedQSum); setRunning(true); open?.({ key: "dma-leak-analysis-progress", @@ -213,12 +226,22 @@ const AnalysisParameters: React.FC<Props> = ({ <TextField type="number" size="small" - value={qSum} + value={qSumInput} onChange={(e) => { - const value = Number(e.target.value); - setFormField("qSum", Number.isNaN(value) ? 1440 : Math.max(360, value)); + const rawValue = e.target.value; + setQSumInput(rawValue); + const value = Number(rawValue); + if (rawValue.trim() !== "" && Number.isFinite(value)) { + setFormField("qSum", value); + } }} inputProps={{ min: 360, step: 10 }} + error={qSumInput.trim() !== "" && !qSumIsValid} + helperText={ + qSumInput.trim() !== "" && !qSumIsValid + ? `需不小于 360 ${FLOW_DISPLAY_UNIT}` + : " " + } /> <Box sx={{ -- 2.54.0 From 59447a100c8bf7c1badda16c0bea48b1b45f7330 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 14:25:03 +0800 Subject: [PATCH 232/281] fix(scada): align device panel collapse animation --- src/components/olmap/SCADA/SCADADeviceList.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/olmap/SCADA/SCADADeviceList.tsx b/src/components/olmap/SCADA/SCADADeviceList.tsx index 4952435..ce8bcf3 100644 --- a/src/components/olmap/SCADA/SCADADeviceList.tsx +++ b/src/components/olmap/SCADA/SCADADeviceList.tsx @@ -814,8 +814,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ variant="persistent" hideBackdrop sx={{ - width: isExpanded ? 360 : 0, - flexShrink: 0, + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + overflow: "hidden", + pointerEvents: "none", "& .MuiDrawer-paper": { width: 360, height: "860px", @@ -828,8 +832,9 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", backdropFilter: "blur(8px)", opacity: 0.95, - transition: "all 0.3s ease-in-out", + transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out", border: "none", + pointerEvents: "auto", "&:hover": { opacity: 1, }, -- 2.54.0 From 589cf45aa7b39da368d8e058c683cf4b5e39f3d3 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 15:12:10 +0800 Subject: [PATCH 233/281] fix(map): stabilize tiled style rendering --- .../chat/toolCallStyleHelpers.test.ts | 50 + src/components/chat/toolCallStyleHelpers.ts | 89 +- .../applyJunctionAreaRender.ts | 72 +- .../olmap/HealthRiskAnalysis/Timeline.tsx | 225 +- .../olmap/core/Controls/BaseLayers.test.ts | 63 + .../olmap/core/Controls/BaseLayers.tsx | 96 +- .../olmap/core/Controls/StyleEditorForm.tsx | 953 ++++----- .../olmap/core/Controls/StyleEditorPanel.tsx | 62 +- .../olmap/core/Controls/StyleLegend.tsx | 55 +- .../olmap/core/Controls/Timeline.tsx | 81 +- .../olmap/core/Controls/Toolbar.tsx | 5 +- .../olmap/core/Controls/styleEditorPresets.ts | 4 +- .../olmap/core/Controls/styleEditorTypes.ts | 32 +- .../core/Controls/styleEditorUtils.test.ts | 107 + .../olmap/core/Controls/styleEditorUtils.ts | 530 ++--- .../olmap/core/Controls/useStyleEditor.ts | 1811 +++++++---------- src/components/olmap/core/MapComponent.tsx | 610 +++--- .../olmap/core/layerStyleController.test.ts | 87 + .../olmap/core/layerStyleController.ts | 126 ++ .../olmap/core/mapLifecycle.test.ts | 25 + src/components/olmap/core/mapLifecycle.ts | 39 +- .../olmap/core/operationalLayers.test.ts | 84 + .../olmap/core/operationalLayers.ts | 33 +- .../olmap/core/tileFeatureIndex.test.ts | 101 + src/components/olmap/core/tileFeatureIndex.ts | 322 +++ .../olmap/core/vectorTileStyleSession.test.ts | 245 +++ .../olmap/core/vectorTileStyleSession.ts | 414 ++++ src/components/olmap/core/vectorTileUtils.ts | 24 + 28 files changed, 3884 insertions(+), 2461 deletions(-) create mode 100644 src/components/chat/toolCallStyleHelpers.test.ts create mode 100644 src/components/olmap/core/Controls/BaseLayers.test.ts create mode 100644 src/components/olmap/core/Controls/styleEditorUtils.test.ts create mode 100644 src/components/olmap/core/layerStyleController.test.ts create mode 100644 src/components/olmap/core/layerStyleController.ts create mode 100644 src/components/olmap/core/operationalLayers.test.ts create mode 100644 src/components/olmap/core/tileFeatureIndex.test.ts create mode 100644 src/components/olmap/core/tileFeatureIndex.ts create mode 100644 src/components/olmap/core/vectorTileStyleSession.test.ts create mode 100644 src/components/olmap/core/vectorTileStyleSession.ts create mode 100644 src/components/olmap/core/vectorTileUtils.ts diff --git a/src/components/chat/toolCallStyleHelpers.test.ts b/src/components/chat/toolCallStyleHelpers.test.ts new file mode 100644 index 0000000..76bdd9a --- /dev/null +++ b/src/components/chat/toolCallStyleHelpers.test.ts @@ -0,0 +1,50 @@ +import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers"; + +describe("parseApplyLayerStylePayload", () => { + it("accepts a valid snake_case interval contract", () => { + expect( + parseApplyLayerStylePayload({ + layer_id: "pipes", + style_config: { + property: "velocity", + classification_method: "custom_breaks", + segments: 3, + custom_breaks: [0, 1, 2, 3], + color_type: "custom", + custom_colors: ["#000000", "#777777", "#ffffff"], + }, + }), + ).toMatchObject({ + layerId: "pipes", + resetToDefault: false, + styleConfig: { segments: 3, customBreaks: [0, 1, 2, 3] }, + }); + }); + + it("rejects invalid class counts and array cardinalities", () => { + expect( + parseApplyLayerStylePayload({ + layer_id: "pipes", + style_config: { segments: 1, property: "velocity" }, + }), + ).toBeNull(); + expect( + parseApplyLayerStylePayload({ + layer_id: "junctions", + style_config: { + segments: 3, + custom_breaks: [0, 1, 2], + }, + }), + ).toBeNull(); + }); + + it("keeps camelCase compatibility", () => { + expect( + parseApplyLayerStylePayload({ + layerId: "junctions", + styleConfig: { opacity: 0.5, colorType: "gradient" }, + }), + ).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } }); + }); +}); diff --git a/src/components/chat/toolCallStyleHelpers.ts b/src/components/chat/toolCallStyleHelpers.ts index 578c097..f62fafc 100644 --- a/src/components/chat/toolCallStyleHelpers.ts +++ b/src/components/chat/toolCallStyleHelpers.ts @@ -1,4 +1,9 @@ -import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes"; +import type { + ClassificationMethod, + ColorType, + StyleConfig, + DefaultLayerStyleId, +} from "@components/olmap/core/Controls/styleEditorTypes"; export type ApplyLayerStyleActionPayload = { layerId: DefaultLayerStyleId; @@ -48,6 +53,23 @@ const asStringArray = (value: unknown): string[] | undefined => .filter((item): item is string => item !== undefined) : undefined; +const asClassificationMethod = (value: unknown): ClassificationMethod | undefined => { + const normalized = asString(value); + return normalized === "pretty_breaks" || normalized === "custom_breaks" + ? normalized + : undefined; +}; + +const asColorType = (value: unknown): ColorType | undefined => { + const normalized = asString(value); + return normalized === "single" || + normalized === "gradient" || + normalized === "rainbow" || + normalized === "custom" + ? normalized + : undefined; +}; + export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => { const normalized = asString(value)?.toLowerCase(); if (normalized === "junctions" || normalized === "pipes") { @@ -77,13 +99,25 @@ export const parseApplyLayerStylePayload = ( ? (params.styleConfig as Record<string, unknown>) : null; + const classificationValue = + rawStyleConfig?.classification_method ?? rawStyleConfig?.classificationMethod; + const colorTypeValue = rawStyleConfig?.color_type ?? rawStyleConfig?.colorType; + const segmentsValue = rawStyleConfig?.segments; + const segments = asNumber(segmentsValue); + if ( + (classificationValue !== undefined && !asClassificationMethod(classificationValue)) || + (colorTypeValue !== undefined && !asColorType(colorTypeValue)) || + (segmentsValue !== undefined && + (!Number.isInteger(segments) || (segments as number) < 2 || (segments as number) > 10)) + ) { + return null; + } + const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig ? { property: asString(rawStyleConfig.property), - classificationMethod: asString( - rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod, - ), - segments: asNumber(rawStyleConfig.segments), + classificationMethod: asClassificationMethod(classificationValue), + segments, minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize), maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize), minStrokeWidth: asNumber( @@ -95,7 +129,7 @@ export const parseApplyLayerStylePayload = ( fixedStrokeWidth: asNumber( rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth, ), - colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType), + colorType: asColorType(colorTypeValue), singlePaletteIndex: asNumber( rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex, ), @@ -121,6 +155,49 @@ export const parseApplyLayerStylePayload = ( } : undefined; + if (styleConfig) { + const numericValues = [ + styleConfig.minSize, + styleConfig.maxSize, + styleConfig.minStrokeWidth, + styleConfig.maxStrokeWidth, + styleConfig.fixedStrokeWidth, + ].filter((value): value is number => value !== undefined); + if (numericValues.some((value) => value <= 0)) return null; + const paletteIndexes: Array<[number | undefined, number]> = [ + [styleConfig.singlePaletteIndex, 7], + [styleConfig.gradientPaletteIndex, 3], + [styleConfig.rainbowPaletteIndex, 2], + ]; + if ( + paletteIndexes.some( + ([index, length]) => + index !== undefined && + (!Number.isInteger(index) || index < 0 || index >= length), + ) + ) return null; + if ( + styleConfig.opacity !== undefined && + (styleConfig.opacity < 0 || styleConfig.opacity > 1) + ) return null; + if ( + styleConfig.customBreaks && + styleConfig.customBreaks.some( + (value, index, values) => index > 0 && value <= values[index - 1], + ) + ) return null; + if ( + styleConfig.segments !== undefined && + styleConfig.customBreaks && + styleConfig.customBreaks.length !== styleConfig.segments + 1 + ) return null; + if ( + styleConfig.segments !== undefined && + styleConfig.customColors && + styleConfig.customColors.length !== styleConfig.segments + ) return null; + } + const hasStyleOverrides = styleConfig && Object.values(styleConfig).some((value) => diff --git a/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts index bd3c7c4..b98099c 100644 --- a/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts +++ b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts @@ -1,9 +1,13 @@ -import { Map as OlMap, VectorTile } from "ol"; +import { Map as OlMap } from "ol"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import VectorTileSource from "ol/source/VectorTile"; import { FlatStyleLike } from "ol/style/flat"; import { config } from "@/config/config"; +import { + VectorTileStyleSession, + versionedPropertyCase, +} from "@components/olmap/core/vectorTileStyleSession"; import { getAreaColor } from "./utils"; const JUNCTION_LAYER_VALUE = "junctions"; @@ -83,40 +87,6 @@ export const applyJunctionAreaRender = ( } }); - const applyFeatureAreaIndex = (renderFeature: any) => { - const featureId = String(renderFeature.get("id") ?? ""); - const areaIndex = nodeAreaIndexMap.get(featureId); - if (areaIndex !== undefined) { - renderFeature.properties_[propertyKey] = areaIndex; - } - }; - - const sourceTiles = (source as any).sourceTiles_; - if (sourceTiles) { - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - }); - } - - const listener = (event: any) => { - try { - if (!(event.tile instanceof VectorTile)) return; - const renderFeatures = event.tile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - } catch (error) { - console.error("Error applying junction area render:", error); - } - }; - - source.on("tileloadend", listener); - const fillCases: any[] = []; areaIds.forEach((areaId, index) => { fillCases.push( @@ -131,14 +101,34 @@ export const applyJunctionAreaRender = ( ); junctionLayer.set(RENDER_OWNER_KEY, ownerId); - junctionLayer.setStyle({ - ...config.MAP_DEFAULT_STYLE, - "circle-fill-color": ["case", ...fillCases, defaultFillColor], - "circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], - } as FlatStyleLike); + const session = new VectorTileStyleSession({ + layer: junctionLayer, + source, + propertyKey, + defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike, + buildStyle: (statePropertyKey, versionKey, version) => + ({ + ...config.MAP_DEFAULT_STYLE, + "circle-fill-color": versionedPropertyCase( + statePropertyKey, + versionKey, + version, + fillCases, + defaultFillColor, + ), + "circle-stroke-color": versionedPropertyCase( + statePropertyKey, + versionKey, + version, + fillCases, + defaultStrokeColor, + ), + }) as FlatStyleLike, + }); + session.commit(nodeAreaIndexMap); return () => { - source.un("tileloadend", listener); + session.dispose(); if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) { junctionLayer.unset(RENDER_OWNER_KEY, true); junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index c169883..b0bb85a 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -3,6 +3,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useNotification } from "@refinedev/core"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike } from "ol/style/flat"; import { Box, @@ -31,6 +33,10 @@ import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; import { useMap } from "@components/olmap/core/MapComponent"; import { useTimelineTimeConfig } from "@components/olmap/core/Controls/useTimelineTimeConfig"; +import { + VectorTileStyleSession, + versionedPropertyCase, +} from "@components/olmap/core/vectorTileStyleSession"; import { useHealthRisk } from "./HealthRiskContext"; import { PredictionResult, @@ -54,6 +60,50 @@ const getRoundedDate = (date: Date, stepMinutes: number): Date => { return roundedDate; }; +const buildHealthRiskStyle = ( + propertyKey: string, + versionKey: string, + version: number, +): FlatStyleLike => { + const colorCases: any[] = []; + const widthCases: any[] = []; + RISK_BREAKS.forEach((breakValue, index) => { + colorCases.push( + ["<=", ["get", propertyKey], breakValue], + RAINBOW_COLORS[index], + ); + widthCases.push( + ["<=", ["get", propertyKey], breakValue], + 2 + (1 - index / (RISK_BREAKS.length - 1)) * 4, + ); + }); + return { + "stroke-color": versionedPropertyCase( + propertyKey, + versionKey, + version, + colorCases, + "rgba(128, 128, 128, 1)", + ), + "stroke-width": versionedPropertyCase( + propertyKey, + versionKey, + version, + widthCases, + 2, + ), + }; +}; + +const getSurvivalProbabilityAtYear = ( + survivalFunction: SurvivalFunction, + index: number, +) => { + const values = survivalFunction.y; + if (values.length === 0) return 1; + return values[Math.max(0, Math.min(index, values.length - 1))]; +}; + interface TimelineProps { schemeDate?: Date; timeRange?: { start: Date; end: Date }; @@ -93,10 +143,10 @@ const Timeline: React.FC<TimelineProps> = ({ const [isPlaying, setIsPlaying] = useState<boolean>(false); const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒 const [isPredicting, setIsPredicting] = useState<boolean>(false); + const [sliderPreviewYear, setSliderPreviewYear] = useState<number | null>(null); const { stepMinutes } = useTimelineTimeConfig(); - // 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定 - const healthDataRef = useRef<Map<string, number>>(new Map()); + const healthStyleSessionRef = useRef<VectorTileStyleSession | null>(null); // 计算时间轴范围 (4-73) const minTime = 4; @@ -105,9 +155,6 @@ const Timeline: React.FC<TimelineProps> = ({ const intervalRef = useRef<NodeJS.Timeout | null>(null); const timelineRef = useRef<HTMLDivElement>(null); - // 添加防抖引用 - const debounceRef = useRef<NodeJS.Timeout | null>(null); - // 时间刻度数组 (4-73,每3个单位一个刻度) const valueMarks = Array.from({ length: 24 }, (_, i) => ({ value: 4 + i * 3, @@ -129,15 +176,18 @@ const Timeline: React.FC<TimelineProps> = ({ if (value < minTime || value > maxTime) { return; } - // 防抖设置currentYear,避免频繁触发数据获取 - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - debounceRef.current = setTimeout(() => { - setCurrentYear(value); - }, 500); // 500ms 防抖延迟 + setSliderPreviewYear(value); }, - [minTime, maxTime, setCurrentYear], + [minTime, maxTime], + ); + + const handleSliderChangeCommitted = useCallback( + (_event: Event | React.SyntheticEvent, newValue: number | number[]) => { + const value = Array.isArray(newValue) ? newValue[0] : newValue; + setSliderPreviewYear(null); + setCurrentYear(value); + }, + [setCurrentYear], ); // 播放控制 @@ -241,9 +291,6 @@ const Timeline: React.FC<TimelineProps> = ({ if (intervalRef.current) { clearInterval(intervalRef.current); } - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } }; }, [stepMinutes]); @@ -261,143 +308,50 @@ const Timeline: React.FC<TimelineProps> = ({ ) ?? null; }, [map]); - // 根据索引从 survival_function 中获取生存概率 - const getSurvivalProbabilityAtYear = useCallback( - (survivalFunc: SurvivalFunction, index: number): number => { - const { y } = survivalFunc; - if (y.length === 0) return 1; - - // 确保索引在范围内 - const safeIndex = Math.max(0, Math.min(index, y.length - 1)); - return y[safeIndex]; - }, - [], - ); - - // 更新管道图层中的 healthRisk 属性 - const updatePipeHealthData = useCallback( - (healthData: Map<string, number>) => { - if (!pipeLayer) return; - const source = pipeLayer.getSource() as any; - if (!source) return; - - const sourceTiles = source.sourceTiles_; - if (!sourceTiles) return; - - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = healthData.get(featureId); - if (value !== undefined) { - renderFeature.properties_["healthRisk"] = value; - } - }); - }); - }, - [pipeLayer], - ); - - // 监听瓦片加载,为新瓦片设置 healthRisk 属性 - // 只在 pipeLayer 变化时绑定一次,通过 ref 获取最新数据 useEffect(() => { if (!pipeLayer) return; - const source = pipeLayer.getSource() as any; + const source = pipeLayer.getSource() as VectorTileSource | null; if (!source) return; - const listener = (event: any) => { - const vectorTile = event.tile; - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - - const healthData = healthDataRef.current; - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = healthData.get(featureId); - if (value !== undefined) { - renderFeature.properties_["healthRisk"] = value; - } - }); - }; - - source.on("tileloadend", listener); + const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike; + healthStyleSessionRef.current?.dispose(); + healthStyleSessionRef.current = new VectorTileStyleSession({ + layer: pipeLayer, + source, + propertyKey: "healthRisk", + defaultStyle: defaultFlatStyle, + buildStyle: buildHealthRiskStyle, + map, + buffered: true, + }); return () => { - source.un("tileloadend", listener); + healthStyleSessionRef.current?.dispose(); + healthStyleSessionRef.current = null; + pipeLayer.setStyle(defaultFlatStyle); }; - }, [pipeLayer]); + }, [map, pipeLayer]); - // 应用样式到管道图层 - const applyPipeHealthStyle = useCallback(() => { - if (!pipeLayer || predictionResults.length === 0) { + useEffect(() => { + const session = healthStyleSessionRef.current; + if (!session) return; + if (predictionResults.length === 0) { + session.reset(); return; } - // 为每条管道计算当前年份的生存概率 const pipeHealthData = new Map<string, number>(); predictionResults.forEach((result) => { const probability = getSurvivalProbabilityAtYear( result.survival_function, - currentYear - 4, // 使用索引 (0-based) + currentYear - 4, ); pipeHealthData.set(result.link_id, probability); }); + session.commit(pipeHealthData); + }, [currentYear, pipeLayer, predictionResults]); - // 更新 ref 数据 - healthDataRef.current = pipeHealthData; - - // 更新图层数据 - updatePipeHealthData(pipeHealthData); - - // 获取所有概率值用于分类 - const probabilities = Array.from(pipeHealthData.values()); - if (probabilities.length === 0) return; - - // 使用等距分段,从0-1分为十类 - const breaks = RISK_BREAKS; - - // 生成彩虹色(从紫色到红色,低生存概率=高风险=红色) - const colors = RAINBOW_COLORS; - - // 构建 WebGL 样式表达式 - const colorCases: any[] = []; - const widthCases: any[] = []; - - breaks.forEach((breakValue, index) => { - const colorStr = colors[index]; - // 线宽根据健康风险调整:低生存概率(高风险)用粗线 - const width = 2 + (1 - index / (breaks.length - 1)) * 4; - - colorCases.push(["<=", ["get", "healthRisk"], breakValue], colorStr); - widthCases.push(["<=", ["get", "healthRisk"], breakValue], width); - }); - // console.log( - // `应用健康风险样式,年份: ${currentYear}, 分段: ${breaks.length}`, - // ); - // console.log("颜色表达式:", colorCases); - // console.log("宽度表达式:", widthCases); - // 应用样式到图层 - pipeLayer.setStyle({ - "stroke-color": ["case", ...colorCases, "rgba(128, 128, 128, 1)"], - "stroke-width": ["case", ...widthCases, 2], - }); - }, [ - pipeLayer, - predictionResults, - currentYear, - getSurvivalProbabilityAtYear, - updatePipeHealthData, - ]); - - // 监听依赖变化,更新样式 - useEffect(() => { - if (predictionResults.length > 0 && pipeLayer) { - applyPipeHealthStyle(); - } - }, [applyPipeHealthStyle, pipeLayer, predictionResults.length]); - - // 这里防止地图缩放时,瓦片重新加载引起的属性更新出错 + // 缩放期间暂停时间轴,避免视图变化与自动播放同时推进。 useEffect(() => { // 监听地图缩放事件,缩放时停止播放 if (map) { @@ -640,12 +594,13 @@ const Timeline: React.FC<TimelineProps> = ({ <Box ref={timelineRef} sx={{ px: 2, position: "relative" }}> <Slider - value={currentYear} + value={sliderPreviewYear ?? currentYear} min={minTime} max={maxTime} // 4-73的范围 step={1} // 每1个单位一个步进 marks={valueMarks} // 显示刻度 onChange={handleSliderChange} + onChangeCommitted={handleSliderChangeCommitted} valueLabelDisplay="auto" sx={{ zIndex: 10, diff --git a/src/components/olmap/core/Controls/BaseLayers.test.ts b/src/components/olmap/core/Controls/BaseLayers.test.ts new file mode 100644 index 0000000..facafef --- /dev/null +++ b/src/components/olmap/core/Controls/BaseLayers.test.ts @@ -0,0 +1,63 @@ +jest.mock("../MapComponent", () => ({ + useData: jest.fn(), + useMap: jest.fn(), +})); +jest.mock("../mapLifecycle", () => ({ + markMapResourcePersistent: <T,>(resource: T) => resource, +})); + +jest.mock("ol/source/XYZ.js", () => ({ + __esModule: true, + default: class MockXyzSource { + constructor(readonly options: unknown) {} + }, +})); +jest.mock("ol/layer/Tile.js", () => ({ + __esModule: true, + default: class MockTileLayer { + private readonly source: unknown; + constructor(options: any) { + this.source = options.source; + } + getSource() { return this.source; } + }, +})); +jest.mock("ol/layer/Group", () => ({ + __esModule: true, + default: class MockGroup { + private readonly layers: unknown[]; + constructor(options: any) { + this.layers = options.layers; + } + getLayers() { return { getArray: () => this.layers }; } + }, +})); + +import { + createBaseLayerEntries, + createBaseLayerSources, +} from "./BaseLayers"; + +const getLeafSources = (layer: any): unknown[] => { + const childLayers = layer.getLayers?.().getArray?.(); + if (Array.isArray(childLayers)) { + return childLayers.flatMap(getLeafSources); + } + return [layer.getSource?.()]; +}; + +describe("base layer resources", () => { + it("creates independent layers backed by one shared source pool", () => { + const sources = createBaseLayerSources(); + const primary = createBaseLayerEntries(sources); + const compare = createBaseLayerEntries(sources); + + expect(primary).toHaveLength(compare.length); + primary.forEach((entry, index) => { + expect(entry.layer).not.toBe(compare[index].layer); + expect(getLeafSources(entry.layer)).toEqual( + getLeafSources(compare[index].layer), + ); + }); + }); +}); diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 8685f67..64483a6 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -30,91 +30,92 @@ const BASE_LAYER_METADATA = [ { id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src }, ] as const; -const createTileLayer = (url: string, attributions: string) => - new TileLayer({ - source: new XYZ({ - url, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions, - }), +const createTileSource = (url: string, attributions: string) => + new XYZ({ + url, + tileSize: 512, + maxZoom: 20, + projection: "EPSG:3857", + attributions, }); -const createBaseLayerEntries = () => { - const streetsLayer = createTileLayer( +export const createBaseLayerSources = () => ({ + streets: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' - ); - const lightMapLayer = createTileLayer( + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', + ), + light: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' - ); - const satelliteLayer = createTileLayer( + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', + ), + satellite: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' - ); - const satelliteStreetsLayer = createTileLayer( + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', + ), + satelliteStreets: createTileSource( `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>' - ); - - const tiandituVectorLayer = new TileLayer({ - source: new XYZ({ + '数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>', + ), + tiandituVector: new XYZ({ url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), - }); - const tiandituVectorAnnotationLayer = new TileLayer({ - source: new XYZ({ + }), + tiandituVectorAnnotation: new XYZ({ url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), - }); - const tiandituImageLayer = new TileLayer({ - source: new XYZ({ + }), + tiandituImage: new XYZ({ url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), - }); - const tiandituImageAnnotationLayer = new TileLayer({ - source: new XYZ({ + }), + tiandituImageAnnotation: new XYZ({ url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, projection: "EPSG:3857", attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', - }), - }); + }), +}); + +export type BaseLayerSources = ReturnType<typeof createBaseLayerSources>; + +export const createBaseLayerEntries = (sources: BaseLayerSources) => { + const tileLayer = (source: XYZ) => new TileLayer({ source }); return [ { ...BASE_LAYER_METADATA[0], - layer: lightMapLayer, + layer: tileLayer(sources.light), }, { ...BASE_LAYER_METADATA[1], - layer: satelliteLayer, + layer: tileLayer(sources.satellite), }, { ...BASE_LAYER_METADATA[2], - layer: satelliteStreetsLayer, + layer: tileLayer(sources.satelliteStreets), }, { ...BASE_LAYER_METADATA[3], - layer: streetsLayer, + layer: tileLayer(sources.streets), }, { ...BASE_LAYER_METADATA[4], layer: new Group({ - layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], + layers: [ + tileLayer(sources.tiandituVector), + tileLayer(sources.tiandituVectorAnnotation), + ], }), }, { ...BASE_LAYER_METADATA[5], layer: new Group({ - layers: [tiandituImageLayer, tiandituImageAnnotationLayer], + layers: [ + tileLayer(sources.tiandituImage), + tileLayer(sources.tiandituImageAnnotation), + ], }), }, ].map((entry) => ({ @@ -130,6 +131,7 @@ const BaseLayers: React.FC = () => { if (data?.maps?.length) return data.maps; return map ? [map] : []; }, [data?.maps, map]); + const sharedSources = useMemo(() => createBaseLayerSources(), []); const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>()); const [isShow, setShow] = useState(false); const [isExpanded, setExpanded] = useState(false); @@ -139,7 +141,7 @@ const BaseLayers: React.FC = () => { maps.forEach((targetMap) => { let layerEntries = layerSetsRef.current.get(targetMap); if (!layerEntries) { - layerEntries = createBaseLayerEntries(); + layerEntries = createBaseLayerEntries(sharedSources); layerSetsRef.current.set(targetMap, layerEntries); } @@ -151,7 +153,7 @@ const BaseLayers: React.FC = () => { layerInfo.layer.setVisible(layerInfo.id === activeId); }); }); - }, [activeId, maps]); + }, [activeId, maps, sharedSources]); const changeMapLayers = (id: string) => { maps.forEach((targetMap) => { diff --git a/src/components/olmap/core/Controls/StyleEditorForm.tsx b/src/components/olmap/core/Controls/StyleEditorForm.tsx index eabf1ea..4ea0627 100644 --- a/src/components/olmap/core/Controls/StyleEditorForm.tsx +++ b/src/components/olmap/core/Controls/StyleEditorForm.tsx @@ -1,17 +1,21 @@ import ApplyIcon from "@mui/icons-material/Check"; -import ColorLensIcon from "@mui/icons-material/ColorLens"; +import PaletteIcon from "@mui/icons-material/PaletteOutlined"; import ResetIcon from "@mui/icons-material/Refresh"; import { Box, Button, Checkbox, + Chip, + Divider, FormControl, FormControlLabel, + IconButton, InputLabel, MenuItem, Select, Slider, TextField, + Tooltip, Typography, } from "@mui/material"; import React from "react"; @@ -23,14 +27,74 @@ import { RAINBOW_PALETTES, SINGLE_COLOR_PALETTES, } from "./styleEditorPresets"; -import { StyleEditorFormProps } from "./styleEditorTypes"; +import type { + ClassificationMethod, + ColorType, + StyleEditorFormProps, +} from "./styleEditorTypes"; import { - getSizePreviewColors, + getDefaultCustomColors, hexToRgba, resolveStyleColors, rgbaToHex, } from "./styleEditorUtils"; +const sectionSx = { px: 2, py: 1.5 } as const; +const sliderSx = { mx: 1, width: "calc(100% - 16px)" } as const; + +type PalettePreview = { name: string; colors: string[]; index: number }; + +const getPalettePreviews = (colorType: ColorType): PalettePreview[] => { + switch (colorType) { + case "single": + return SINGLE_COLOR_PALETTES.map((palette, index) => ({ + name: `单色 ${index + 1}`, + colors: [palette.color], + index, + })); + case "gradient": + return GRADIENT_PALETTES.map((palette, index) => ({ + name: palette.name, + colors: [palette.start, palette.end], + index, + })); + case "rainbow": + return RAINBOW_PALETTES.map((palette, index) => ({ + name: palette.name, + colors: palette.colors, + index, + })); + default: + return []; + } +}; + +const getSelectedPaletteIndex = (styleConfig: StyleEditorFormProps["styleConfig"]) => { + switch (styleConfig.colorType) { + case "single": + return styleConfig.singlePaletteIndex; + case "gradient": + return styleConfig.gradientPaletteIndex; + case "rainbow": + return styleConfig.rainbowPaletteIndex; + default: + return -1; + } +}; + +const selectPalette = (colorType: ColorType, index: number) => { + switch (colorType) { + case "single": + return { singlePaletteIndex: index }; + case "gradient": + return { gradientPaletteIndex: index }; + case "rainbow": + return { rainbowPaletteIndex: index }; + default: + return {}; + } +}; + const StyleEditorForm: React.FC<StyleEditorFormProps> = ({ renderLayers, selectedRenderLayer, @@ -42,548 +106,399 @@ const StyleEditorForm: React.FC<StyleEditorFormProps> = ({ onClassificationMethodChange, onSegmentsChange, onCustomBreakChange, - onCustomBreakBlur, onColorTypeChange, onApply, onReset, + validationErrors, + isDirty, + isApplying, }) => { - const renderColorSetting = () => { - if (styleConfig.colorType === "single") { - return ( - <FormControl variant="standard" fullWidth margin="dense" className="mt-3"> - <InputLabel>单一色方案</InputLabel> - <Select - value={styleConfig.singlePaletteIndex} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - singlePaletteIndex: Number(e.target.value), - })) - } - > - {SINGLE_COLOR_PALETTES.map((palette, index) => ( - <MenuItem key={index} value={index}> - <Box width="100%" sx={{ display: "flex", alignItems: "center" }}> - <Box - sx={{ - width: "80%", - height: 16, - borderRadius: 2, - background: palette.color, - marginRight: 1, - border: "1px solid #ccc", - }} - /> - </Box> - </MenuItem> - ))} - </Select> - </FormControl> - ); - } + const layerType = selectedRenderLayer?.get("type"); + const previewColors = resolveStyleColors(styleConfig); + const selectedProperty = availableProperties.some( + (property) => property.value === styleConfig.property, + ) + ? styleConfig.property + : ""; - if (styleConfig.colorType === "gradient") { - return ( - <FormControl variant="standard" fullWidth margin="dense" className="mt-3"> - <InputLabel>渐进色方案</InputLabel> - <Select - value={styleConfig.gradientPaletteIndex} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - gradientPaletteIndex: Number(e.target.value), - })) - } - > - {GRADIENT_PALETTES.map((palette, index) => { - const previewColors = resolveStyleColors( - { ...styleConfig, colorType: "gradient", gradientPaletteIndex: index }, - styleConfig.segments + 1 - ); - return ( - <MenuItem key={index} value={index}> - <Box width="100%" sx={{ display: "flex", alignItems: "center" }}> - <Box - sx={{ - width: "80%", - height: 16, - borderRadius: 2, - display: "flex", - overflow: "hidden", - marginRight: 1, - border: "1px solid #ccc", - }} - > - {previewColors.map((color, colorIndex) => ( - <Box - key={colorIndex} - sx={{ flex: 1, backgroundColor: color }} - /> - ))} - </Box> - </Box> - </MenuItem> - ); - })} - </Select> - </FormControl> + const setCustomColor = (index: number, hex: string) => { + setStyleConfig((previous) => { + const customColors = getDefaultCustomColors( + previous.segments, + previous.customColors, ); - } - - if (styleConfig.colorType === "rainbow") { - return ( - <FormControl variant="standard" fullWidth margin="dense" className="mt-3"> - <InputLabel>离散彩虹方案</InputLabel> - <Select - value={styleConfig.rainbowPaletteIndex} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - rainbowPaletteIndex: Number(e.target.value), - })) - } - > - {RAINBOW_PALETTES.map((palette, index) => { - const previewColors = Array.from( - { length: styleConfig.segments + 1 }, - (_, colorIndex) => palette.colors[colorIndex % palette.colors.length] - ); - return ( - <MenuItem key={index} value={index}> - <Box width="100%" sx={{ display: "flex", alignItems: "center" }}> - <Typography sx={{ marginRight: 1 }}>{palette.name}</Typography> - <Box - sx={{ - width: "60%", - height: 16, - borderRadius: 2, - display: "flex", - border: "1px solid #ccc", - overflow: "hidden", - }} - > - {previewColors.map((color, colorIndex) => ( - <Box - key={colorIndex} - sx={{ flex: 1, backgroundColor: color }} - /> - ))} - </Box> - </Box> - </MenuItem> - ); - })} - </Select> - </FormControl> - ); - } - - if (styleConfig.colorType === "custom") { - return ( - <Box className="mt-3"> - <Typography variant="subtitle2" gutterBottom> - 自定义颜色 - </Typography> - <Box - className="flex flex-col gap-2" - sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }} - > - {Array.from({ length: styleConfig.segments }).map((_, index) => { - const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)"; - return ( - <Box key={index} className="flex items-center gap-2"> - <Typography variant="caption" sx={{ width: 40 }}> - 分段{index + 1} - </Typography> - <input - type="color" - value={rgbaToHex(color)} - onChange={(e) => { - const nextColor = hexToRgba(e.target.value); - setStyleConfig((prev) => { - const nextColors = [...(prev.customColors || [])]; - while (nextColors.length < prev.segments) { - nextColors.push("rgba(0,0,0,1)"); - } - nextColors[index] = nextColor; - return { ...prev, customColors: nextColors }; - }); - }} - style={{ - width: "100%", - height: "32px", - cursor: "pointer", - border: "1px solid #ccc", - borderRadius: "4px", - }} - /> - </Box> - ); - })} - </Box> - </Box> - ); - } - - return null; + customColors[index] = hexToRgba(hex); + return { ...previous, customColors }; + }); }; - const renderSizeSetting = () => { - const previewColors = getSizePreviewColors(styleConfig); + const renderPalette = () => { + const palettes = getPalettePreviews(styleConfig.colorType); + if (palettes.length === 0) return null; + const selectedIndex = getSelectedPaletteIndex(styleConfig); - if (selectedRenderLayer?.get("type") === "point") { - return ( - <Box className="mt-3"> - <Typography gutterBottom> - 点大小范围: {styleConfig.minSize} - {styleConfig.maxSize} 像素 - </Typography> - <Box className="flex items-center gap-4"> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最小值 - </Typography> - <Slider - value={styleConfig.minSize} - onChange={(_, value) => - setStyleConfig((prev) => ({ ...prev, minSize: value as number })) - } - min={2} - max={8} - step={1} - size="small" - /> - </Box> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最大值 - </Typography> - <Slider - value={styleConfig.maxSize} - onChange={(_, value) => - setStyleConfig((prev) => ({ ...prev, maxSize: value as number })) - } - min={10} - max={16} - step={1} - size="small" - /> - </Box> - </Box> - <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> - <Typography variant="caption">预览:</Typography> + return ( + <Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}> + {palettes.map((palette) => ( + <Tooltip key={palette.name} title={palette.name} placement="top"> <Box + component="button" + type="button" + aria-label={palette.name} + onClick={() => + setStyleConfig((previous) => ({ + ...previous, + ...selectPalette(previous.colorType, palette.index), + })) + } sx={{ - width: styleConfig.minSize, - height: styleConfig.minSize, - borderRadius: "50%", - backgroundColor: previewColors[0], + display: "flex", + height: 30, + p: 0.5, + overflow: "hidden", + cursor: "pointer", + bgcolor: "background.paper", + border: "1px solid", + borderColor: selectedIndex === palette.index ? "primary.main" : "divider", + borderRadius: 1, + boxShadow: selectedIndex === palette.index ? "0 0 0 1px currentColor" : "none", }} - /> - <Typography variant="caption">到</Typography> - <Box - sx={{ - width: styleConfig.maxSize, - height: styleConfig.maxSize, - borderRadius: "50%", - backgroundColor: previewColors[previewColors.length - 1], - }} - /> - </Box> - </Box> - ); - } - - if (selectedRenderLayer?.get("type") === "linestring") { - return ( - <Box className="mt-3"> - <FormControlLabel - control={ - <Checkbox - checked={styleConfig.adjustWidthByProperty} - onChange={(e) => - setStyleConfig((prev) => ({ - ...prev, - adjustWidthByProperty: e.target.checked, - })) - } - disabled={styleConfig.colorType === "single"} - /> - } - label="根据数值分段调整线条宽度" - /> - {styleConfig.adjustWidthByProperty ? ( - <> - <Typography gutterBottom> - 线条宽度范围: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth} - px - </Typography> - <Box className="flex items-center gap-4"> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最小值 - </Typography> - <Slider - value={styleConfig.minStrokeWidth} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - minStrokeWidth: value as number, - })) - } - min={1} - max={4} - step={0.5} - size="small" - /> - </Box> - <Box className="flex-1"> - <Typography variant="caption" gutterBottom> - 最大值 - </Typography> - <Slider - value={styleConfig.maxStrokeWidth} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - maxStrokeWidth: value as number, - })) - } - min={6} - max={12} - step={0.5} - size="small" - /> - </Box> - </Box> - <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> - <Typography variant="caption">预览:</Typography> - <Box - sx={{ - width: 50, - height: styleConfig.minStrokeWidth, - backgroundColor: previewColors[0], - border: `1px solid ${previewColors[0]}`, - borderRadius: 1, - }} - /> - <Typography variant="caption">到</Typography> - <Box - sx={{ - width: 50, - height: styleConfig.maxStrokeWidth, - backgroundColor: previewColors[previewColors.length - 1], - border: `1px solid ${previewColors[previewColors.length - 1]}`, - borderRadius: 1, - }} - /> - </Box> - </> - ) : ( - <> - <Typography gutterBottom> - 固定线条宽度: {styleConfig.fixedStrokeWidth}px - </Typography> - <Slider - value={styleConfig.fixedStrokeWidth} - onChange={(_, value) => - setStyleConfig((prev) => ({ - ...prev, - fixedStrokeWidth: value as number, - })) - } - min={1} - max={10} - step={0.5} - size="small" - /> - <Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded"> - <Typography variant="caption">预览:</Typography> - <Box - sx={{ - width: 50, - height: styleConfig.fixedStrokeWidth, - backgroundColor: previewColors[0], - border: `1px solid ${previewColors[0]}`, - borderRadius: 1, - }} - /> - </Box> - </> - )} - </Box> - ); - } - - return null; + > + {palette.colors.map((color, index) => ( + <Box key={`${color}-${index}`} sx={{ flex: 1, bgcolor: color }} /> + ))} + </Box> + </Tooltip> + ))} + </Box> + ); }; return ( - <div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300"> - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel>选择图层</InputLabel> - <Select - value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""} - onChange={(e) => onLayerChange(e.target.value as number)} - > - {renderLayers.map((layer, index) => ( - <MenuItem key={index} value={index}> - {layer.get("name")} - </MenuItem> - ))} - </Select> - </FormControl> - - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel>分级属性</InputLabel> - <Select - value={styleConfig.property} - onChange={(e) => onPropertyChange(e.target.value)} - disabled={!selectedRenderLayer} - > - {availableProperties.map((property) => ( - <MenuItem key={property.name} value={property.value}> - {property.name} - </MenuItem> - ))} - </Select> - </FormControl> - - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel>分类方法</InputLabel> - <Select - value={styleConfig.classificationMethod} - onChange={(e) => onClassificationMethodChange(e.target.value)} - > - {CLASSIFICATION_METHODS.map((method) => ( - <MenuItem key={method.value} value={method.value}> - {method.name} - </MenuItem> - ))} - </Select> - </FormControl> - - <Box className="mt-3"> - <Typography gutterBottom>分类数量: {styleConfig.segments}</Typography> - <Slider - value={styleConfig.segments} - onChange={(_, value) => onSegmentsChange(value as number)} - min={2} - max={10} - step={1} - marks - size="small" - /> + <Box + sx={{ + position: "absolute", + top: 72, + left: 12, + zIndex: 1300, + width: "min(380px, calc(100vw - 24px))", + maxHeight: "calc(100dvh - 92px)", + display: "flex", + flexDirection: "column", + bgcolor: "background.paper", + border: "none", + borderRadius: "12px", + boxShadow: + "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", + backdropFilter: "blur(8px)", + opacity: 0.95, + transition: "opacity 0.3s ease-in-out", + overflow: "hidden", + "&:hover": { + opacity: 1, + }, + }} + > + <Box + sx={{ + px: 2.5, + py: 1.5, + display: "flex", + alignItems: "center", + gap: 1, + bgcolor: "rgba(255,255,255,0.98)", + color: "text.primary", + borderBottom: "1px solid", + borderColor: "divider", + }} + > + <PaletteIcon fontSize="small" sx={{ color: "#257DD4" }} /> + <Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}> + 图层样式 + </Typography> + {isDirty && ( + <Chip + label="待应用" + size="small" + sx={{ + height: 24, + color: "warning.dark", + bgcolor: "warning.50", + border: "1px solid", + borderColor: "warning.200", + }} + /> + )} </Box> - {styleConfig.classificationMethod === "custom_breaks" && ( - <Box className="mt-3 p-2 bg-gray-50 rounded"> - <Typography variant="subtitle2" gutterBottom> - 手动设置区间阈值(按升序填写,最小值 {">="} 0) + <Box sx={{ overflowY: "auto", overflowX: "hidden", minHeight: 0 }}> + <Box sx={sectionSx}> + <Typography variant="overline" color="text.secondary"> + 图层与数据 </Typography> - <Box - className="flex flex-col gap-2" - sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }} - > - {Array.from({ length: styleConfig.segments }).map((_, index) => ( - <TextField - key={index} - label={`阈值 ${index + 1}`} - type="number" - size="small" - slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }} - value={styleConfig.customBreaks?.[index] ?? ""} - onChange={(e) => onCustomBreakChange(index, e.target.value)} - onBlur={onCustomBreakBlur} - /> - ))} + <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25, mt: 0.5 }}> + <FormControl size="small" fullWidth> + <InputLabel>图层</InputLabel> + <Select + label="图层" + value={selectedRenderLayer?.get("value") || ""} + onChange={(event) => onLayerChange(String(event.target.value))} + > + {renderLayers.map((layer) => ( + <MenuItem key={layer.get("value")} value={layer.get("value")}> + {layer.get("name")} + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl size="small" fullWidth> + <InputLabel>属性</InputLabel> + <Select + label="属性" + value={selectedProperty} + onChange={(event) => onPropertyChange(String(event.target.value))} + > + {availableProperties.map((property) => ( + <MenuItem key={property.value} value={property.value}> + {property.name} + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl size="small" fullWidth> + <InputLabel>分级方法</InputLabel> + <Select + label="分级方法" + value={styleConfig.classificationMethod} + onChange={(event) => + onClassificationMethodChange(event.target.value as ClassificationMethod) + } + > + {CLASSIFICATION_METHODS.map((method) => ( + <MenuItem key={method.value} value={method.value}> + {method.name} + </MenuItem> + ))} + </Select> + </FormControl> + <TextField + size="small" + label="分类数量" + type="number" + value={styleConfig.segments} + onChange={(event) => onSegmentsChange(Number(event.target.value))} + slotProps={{ htmlInput: { min: 2, max: 10, step: 1 } }} + /> </Box> </Box> - )} - <FormControl variant="standard" fullWidth margin="dense"> - <InputLabel> - <ColorLensIcon className="mr-1" /> - 颜色方案 - </InputLabel> - <Select - value={styleConfig.colorType} - onChange={(e) => onColorTypeChange(e.target.value)} - > - {COLOR_TYPE_OPTIONS.map((option) => ( - <MenuItem key={option.value} value={option.value}> - {option.label} - </MenuItem> - ))} - </Select> - {renderColorSetting()} - </FormControl> + <Divider /> + <Box sx={sectionSx}> + <Typography variant="overline" color="text.secondary"> + 分级与色带 + </Typography> + <FormControl size="small" fullWidth sx={{ mt: 0.5, mb: 1 }}> + <InputLabel>颜色方案</InputLabel> + <Select + label="颜色方案" + value={styleConfig.colorType} + onChange={(event) => onColorTypeChange(event.target.value as ColorType)} + > + {COLOR_TYPE_OPTIONS.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + </FormControl> + {renderPalette()} - {renderSizeSetting()} + {styleConfig.classificationMethod === "custom_breaks" && ( + <Box sx={{ mt: 1.25, display: "grid", gap: 0.75 }}> + {Array.from({ length: styleConfig.segments }, (_, index) => ( + <Box + key={index} + sx={{ + display: "grid", + gridTemplateColumns: + styleConfig.colorType === "custom" ? "32px 1fr 12px 1fr" : "12px 1fr 12px 1fr", + gap: 0.75, + alignItems: "center", + }} + > + {styleConfig.colorType === "custom" ? ( + <Tooltip title={`区间 ${index + 1} 颜色`}> + <Box + component="input" + type="color" + aria-label={`区间 ${index + 1} 颜色`} + value={rgbaToHex(styleConfig.customColors?.[index] || previewColors[index])} + onChange={(event: React.ChangeEvent<HTMLInputElement>) => + setCustomColor(index, event.target.value) + } + sx={{ width: 32, height: 30, p: 0, border: 0, bgcolor: "transparent" }} + /> + </Tooltip> + ) : ( + <Box sx={{ width: 10, height: 24, bgcolor: previewColors[index], borderRadius: 0.5 }} /> + )} + <TextField + size="small" + type="number" + value={Number.isFinite(styleConfig.customBreaks?.[index]) ? styleConfig.customBreaks?.[index] : ""} + onChange={(event) => onCustomBreakChange(index, event.target.value)} + inputProps={{ "aria-label": `区间 ${index + 1} 下界`, step: 0.1 }} + /> + <Typography variant="caption" color="text.secondary" textAlign="center"> + 至 + </Typography> + <TextField + size="small" + type="number" + value={Number.isFinite(styleConfig.customBreaks?.[index + 1]) ? styleConfig.customBreaks?.[index + 1] : ""} + onChange={(event) => onCustomBreakChange(index + 1, event.target.value)} + inputProps={{ "aria-label": `区间 ${index + 1} 上界`, step: 0.1 }} + /> + </Box> + ))} + </Box> + )} + </Box> - <Box className="mt-3"> - <Typography gutterBottom> - 透明度: {(styleConfig.opacity * 100).toFixed(0)}% - </Typography> - <Slider - value={styleConfig.opacity} - onChange={(_, value) => - setStyleConfig((prev) => ({ ...prev, opacity: value as number })) - } - min={0.1} - max={1} - step={0.05} - size="small" - /> + <Divider /> + <Box sx={sectionSx}> + <Typography variant="overline" color="text.secondary"> + 符号 + </Typography> + {layerType === "point" ? ( + <Box sx={{ mt: 0.5 }}> + <Typography variant="caption"> + 节点半径 {styleConfig.minSize} - {styleConfig.maxSize}px + </Typography> + <Slider + value={[styleConfig.minSize, styleConfig.maxSize]} + onChange={(_, value) => { + const [minSize, maxSize] = value as number[]; + setStyleConfig((previous) => ({ ...previous, minSize, maxSize })); + }} + min={2} + max={16} + step={1} + size="small" + sx={sliderSx} + /> + </Box> + ) : ( + <Box sx={{ mt: 0.5 }}> + <FormControlLabel + control={ + <Checkbox + size="small" + checked={styleConfig.adjustWidthByProperty} + onChange={(event) => + setStyleConfig((previous) => ({ + ...previous, + adjustWidthByProperty: event.target.checked, + })) + } + /> + } + label={<Typography variant="body2">按数值调整线宽</Typography>} + /> + <Typography variant="caption"> + {styleConfig.adjustWidthByProperty + ? `${styleConfig.minStrokeWidth} - ${styleConfig.maxStrokeWidth}px` + : `${styleConfig.fixedStrokeWidth}px`} + </Typography> + <Slider + value={ + styleConfig.adjustWidthByProperty + ? [styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth] + : styleConfig.fixedStrokeWidth + } + onChange={(_, value) => + setStyleConfig((previous) => + Array.isArray(value) + ? { ...previous, minStrokeWidth: value[0], maxStrokeWidth: value[1] } + : { ...previous, fixedStrokeWidth: value }, + ) + } + min={1} + max={12} + step={0.5} + size="small" + sx={sliderSx} + /> + </Box> + )} + <Typography variant="caption">透明度 {Math.round(styleConfig.opacity * 100)}%</Typography> + <Slider + value={styleConfig.opacity} + onChange={(_, value) => + setStyleConfig((previous) => ({ ...previous, opacity: value as number })) + } + min={0.1} + max={1} + step={0.05} + size="small" + sx={sliderSx} + /> + </Box> + + <Divider /> + <Box sx={{ ...sectionSx, display: "flex", gap: 2 }}> + <FormControlLabel + control={ + <Checkbox + size="small" + checked={styleConfig.showLabels} + onChange={(event) => + setStyleConfig((previous) => ({ ...previous, showLabels: event.target.checked })) + } + /> + } + label={<Typography variant="body2">数值标注</Typography>} + /> + <FormControlLabel + control={ + <Checkbox + size="small" + checked={styleConfig.showId} + onChange={(event) => + setStyleConfig((previous) => ({ ...previous, showId: event.target.checked })) + } + /> + } + label={<Typography variant="body2">设施 ID</Typography>} + /> + </Box> </Box> - <FormControlLabel - control={ - <Checkbox - checked={styleConfig.showId} - onChange={(e) => - setStyleConfig((prev) => ({ ...prev, showId: e.target.checked })) - } - /> - } - label="显示 ID(缩放 >=15 级时显示)" - /> - - <FormControlLabel - control={ - <Checkbox - checked={styleConfig.showLabels} - onChange={(e) => - setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked })) - } - /> - } - label="显示属性(缩放 >=15 级时显示)" - /> - - <div className="my-3"></div> - - <Box className="flex gap-2"> - <Button - variant="contained" - color="primary" - onClick={onApply} - disabled={!selectedRenderLayer || !styleConfig.property} - startIcon={<ApplyIcon />} - fullWidth - > - 应用 - </Button> - <Button - variant="outlined" - onClick={onReset} - disabled={!selectedRenderLayer} - startIcon={<ResetIcon />} - fullWidth - > - 重置 - </Button> + <Divider /> + <Box sx={{ px: 2, py: 1.25 }}> + {validationErrors[0] && ( + <Typography variant="caption" color="error" sx={{ display: "block", mb: 0.75 }}> + {validationErrors[0]} + </Typography> + )} + <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}> + <Tooltip title="恢复默认样式"> + <IconButton size="small" onClick={onReset} aria-label="恢复默认样式"> + <ResetIcon fontSize="small" /> + </IconButton> + </Tooltip> + <Button + variant="contained" + size="small" + startIcon={<ApplyIcon />} + onClick={onApply} + disabled={!isDirty || validationErrors.length > 0 || isApplying} + > + {isApplying ? "应用中" : "应用"} + </Button> + </Box> </Box> - </div> + </Box> ); }; diff --git a/src/components/olmap/core/Controls/StyleEditorPanel.tsx b/src/components/olmap/core/Controls/StyleEditorPanel.tsx index afc1686..ada7dd8 100644 --- a/src/components/olmap/core/Controls/StyleEditorPanel.tsx +++ b/src/components/olmap/core/Controls/StyleEditorPanel.tsx @@ -1,50 +1,38 @@ import React from "react"; +import { Box, CircularProgress } from "@mui/material"; import StyleEditorForm from "./StyleEditorForm"; -import { createDefaultLayerStyleState, createDefaultLayerStyleStates } from "./styleEditorPresets"; -import { LayerStyleState, StyleConfig, StyleEditorPanelProps } from "./styleEditorTypes"; +import type { StyleEditorPanelProps } from "./styleEditorTypes"; const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({ isReady, - renderLayers, - selectedRenderLayer, - styleConfig, - setStyleConfig, - availableProperties, - onLayerChange, - onPropertyChange, - onClassificationMethodChange, - onSegmentsChange, - onCustomBreakChange, - onCustomBreakBlur, - onColorTypeChange, - onApply, - onReset, + ...formProps }) => { if (!isReady) { - return <div>Loading...</div>; + return ( + <Box + sx={{ + position: "absolute", + top: 72, + left: 12, + zIndex: 1300, + width: 160, + height: 72, + display: "grid", + placeItems: "center", + bgcolor: "background.paper", + borderRadius: "12px", + boxShadow: + "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", + opacity: 0.95, + }} + > + <CircularProgress size={22} /> + </Box> + ); } - return ( - <StyleEditorForm - renderLayers={renderLayers} - selectedRenderLayer={selectedRenderLayer} - styleConfig={styleConfig} - setStyleConfig={setStyleConfig} - availableProperties={availableProperties} - onLayerChange={onLayerChange} - onPropertyChange={onPropertyChange} - onClassificationMethodChange={onClassificationMethodChange} - onSegmentsChange={onSegmentsChange} - onCustomBreakChange={onCustomBreakChange} - onCustomBreakBlur={onCustomBreakBlur} - onColorTypeChange={onColorTypeChange} - onApply={onApply} - onReset={onReset} - /> - ); + return <StyleEditorForm {...formProps} />; }; export default StyleEditorPanel; -export type { LayerStyleState, StyleConfig } from "./styleEditorTypes"; -export { createDefaultLayerStyleState, createDefaultLayerStyleStates }; diff --git a/src/components/olmap/core/Controls/StyleLegend.tsx b/src/components/olmap/core/Controls/StyleLegend.tsx index a1bffe8..597f8d2 100644 --- a/src/components/olmap/core/Controls/StyleLegend.tsx +++ b/src/components/olmap/core/Controls/StyleLegend.tsx @@ -7,34 +7,31 @@ interface LegendStyleConfig { layerId: string; property: string; colors: string[]; - type: string; // 图例类型 - dimensions: number[]; // 尺寸大小 - breaks: number[]; // 分段值 - labels?: string[]; // 可选标签(用于离散分类) + type: string; + dimensions: number[]; + breaks: number[]; + labels?: string[]; columns?: number; itemsPerColumn?: number; } -// 图例组件 -// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息 -// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置 -// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致 - const StyleLegend: React.FC<LegendStyleConfig> = ({ layerName, layerId, property, colors, - type, // 图例类型 + type, dimensions, breaks, labels, columns = 1, itemsPerColumn, }) => { + const itemCount = Math.min(colors.length, dimensions.length, Math.max(0, breaks.length - 1)); return ( <Box key={layerId} - className="bg-white p-3 rounded-xl max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100" + className="bg-white p-3 max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100" + sx={{ borderRadius: 1 }} > <Typography variant="subtitle2" gutterBottom> {layerName} - {property} @@ -56,39 +53,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({ rowGap: 0.5, }} > - {[...Array(breaks.length)].map((_, index) => { - const color = colors[index]; // 默认颜色为黑色 - const dimension = dimensions[index]; // 默认尺寸为16 - - // // 处理第一个区间(小于 breaks[0]) - // if (index === 0) { - // return ( - // <Box key={index} className="flex items-center gap-2 mb-1"> - // <Box - // sx={ - // type === "point" - // ? { - // width: dimension, - // height: dimension, - // borderRadius: "50%", - // backgroundColor: color, - // } - // : { - // width: 16, - // height: dimension, - // backgroundColor: color, - // border: `1px solid ${color}`, - // } - // } - // /> - // <Typography variant="caption" className="text-xs"> - // {"<"} {breaks[0]?.toFixed(1)} - // </Typography> - // </Box> - // ); - // } - - // 处理中间区间(breaks[index] - breaks[index + 1]) + {Array.from({ length: itemCount }, (_, index) => { + const color = colors[index]; + const dimension = dimensions[index]; if (index + 1 < breaks.length) { const prevValue = breaks[index]; const currentValue = breaks[index + 1]; diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index e7d5935..dc7869b 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -107,6 +107,7 @@ const Timeline: React.FC<TimelineProps> = ({ const [calculatedInterval, setCalculatedInterval] = useState<number>(stepMinutes); // 分钟 const [isCalculating, setIsCalculating] = useState<boolean>(false); + const [sliderPreviewTime, setSliderPreviewTime] = useState<number | null>(null); // 计算时间轴范围 const minTime = timeRange @@ -146,8 +147,8 @@ const Timeline: React.FC<TimelineProps> = ({ // 添加缓存引用 const nodeCacheRef = useRef<Map<string, any[]>>(new Map()); const linkCacheRef = useRef<Map<string, any[]>>(new Map()); - // 添加防抖引用 - const debounceRef = useRef<NodeJS.Timeout | null>(null); + const frameRequestRevisionRef = useRef(0); + const frameAbortControllerRef = useRef<AbortController | null>(null); const updateDataStates = useCallback( ( @@ -202,6 +203,7 @@ const Timeline: React.FC<TimelineProps> = ({ target, schemeName, schemeType, + signal, }: { queryTime: Date; junctionProperties: string; @@ -210,6 +212,7 @@ const Timeline: React.FC<TimelineProps> = ({ target: "primary" | "compare"; schemeName?: string; schemeType?: string; + signal?: AbortSignal; }) => { const query_time = queryTime.toISOString(); let nodeRecords: any = { results: [] }; @@ -233,10 +236,12 @@ const Timeline: React.FC<TimelineProps> = ({ nodePromise = sourceType === "scheme" && schemeName ? apiFetch( - `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}` + `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`, + { signal }, ) : apiFetch( - `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}` + `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`, + { signal }, ); requests.push(nodePromise); } @@ -260,10 +265,12 @@ const Timeline: React.FC<TimelineProps> = ({ linkPromise = sourceType === "scheme" && schemeName ? apiFetch( - `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}` + `${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`, + { signal }, ) : apiFetch( - `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}` + `${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`, + { signal }, ); requests.push(linkPromise); } @@ -309,9 +316,13 @@ const Timeline: React.FC<TimelineProps> = ({ ); } - updateDataStates(nodeRecords.results || [], linkRecords.results || [], target); + return { + target, + nodeResults: nodeRecords.results || [], + linkResults: linkRecords.results || [], + }; }, - [buildCacheKey, updateDataStates] + [buildCacheKey] ); const fetchFrameData = useCallback( @@ -322,6 +333,11 @@ const Timeline: React.FC<TimelineProps> = ({ schemeName: string, schemeType: string ) => { + const revision = frameRequestRevisionRef.current + 1; + frameRequestRevisionRef.current = revision; + frameAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + frameAbortControllerRef.current = abortController; const primarySourceType = disableDateSelection && schemeName ? "scheme" : "realtime"; const tasks = [ @@ -333,6 +349,7 @@ const Timeline: React.FC<TimelineProps> = ({ target: "primary", schemeName, schemeType, + signal: abortController.signal, }), ]; @@ -344,13 +361,29 @@ const Timeline: React.FC<TimelineProps> = ({ pipeProperties, sourceType: "realtime", target: "compare", + signal: abortController.signal, }) ); } - await Promise.all(tasks); + try { + const frames = await Promise.all(tasks); + if ( + abortController.signal.aborted || + revision !== frameRequestRevisionRef.current + ) { + return; + } + frames.forEach(({ nodeResults, linkResults, target }) => { + updateDataStates(nodeResults, linkResults, target); + }); + } catch (error) { + if ((error as Error).name !== "AbortError") { + console.error("Timeline frame fetch failed:", error); + } + } }, - [disableDateSelection, fetchDataBySource, isCompareMode] + [disableDateSelection, fetchDataBySource, isCompareMode, updateDataStates] ); // 格式化时间显示 @@ -427,15 +460,18 @@ const Timeline: React.FC<TimelineProps> = ({ if (timeRange && (value < minTime || value > maxTime)) { return; } - // 防抖设置currentTime,避免频繁触发数据获取 - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - debounceRef.current = setTimeout(() => { - setCurrentTime(value); - }, 500); // 500ms 防抖延迟 + setSliderPreviewTime(value); }, - [timeRange, minTime, maxTime, setCurrentTime], + [timeRange, minTime, maxTime], + ); + + const handleSliderChangeCommitted = useCallback( + (_event: Event | React.SyntheticEvent, newValue: number | number[]) => { + const value = Array.isArray(newValue) ? newValue[0] : newValue; + setSliderPreviewTime(null); + setCurrentTime(value); + }, + [setCurrentTime], ); // 播放控制 @@ -585,9 +621,7 @@ const Timeline: React.FC<TimelineProps> = ({ if (intervalRef.current) { clearInterval(intervalRef.current); } - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } + frameAbortControllerRef.current?.abort(); }; }, [durationMinutes, setCurrentTime, stepMinutes]); @@ -731,7 +765,7 @@ const Timeline: React.FC<TimelineProps> = ({ <Draggable nodeRef={draggableRef} handle=".drag-handle"> <div ref={draggableRef} - className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100" + className="absolute bottom-4 left-1/2 z-20 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100" > <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <Paper @@ -937,12 +971,13 @@ const Timeline: React.FC<TimelineProps> = ({ <Box ref={timelineRef} sx={{ px: 2, position: "relative" }}> <Slider - value={timelineCurrentTime} + value={sliderPreviewTime ?? timelineCurrentTime} min={0} max={durationMinutes} step={stepMinutes} marks={timeMarks} onChange={handleSliderChange} + onChangeCommitted={handleSliderChangeCommitted} valueLabelDisplay="auto" valueLabelFormat={formatTime} sx={{ diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 2596ca1..b35f50c 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -138,6 +138,7 @@ const Toolbar: React.FC<ToolbarProps> = ({ const styleEditor = useStyleEditor({ layerStyleStates, setLayerStyleStates, + workspace: project?.workspace || config.MAP_WORKSPACE, }); useToolbarChatActions({ @@ -803,10 +804,12 @@ const Toolbar: React.FC<ToolbarProps> = ({ onClassificationMethodChange={styleEditor.handleClassificationMethodChange} onSegmentsChange={styleEditor.handleSegmentsChange} onCustomBreakChange={styleEditor.handleCustomBreakChange} - onCustomBreakBlur={styleEditor.handleCustomBreakBlur} onColorTypeChange={styleEditor.handleColorTypeChange} onApply={styleEditor.handleApply} onReset={styleEditor.handleReset} + validationErrors={styleEditor.validationErrors} + isDirty={styleEditor.isDirty} + isApplying={styleEditor.isApplying} /> </div> <ToolbarHistoryPanel diff --git a/src/components/olmap/core/Controls/styleEditorPresets.ts b/src/components/olmap/core/Controls/styleEditorPresets.ts index fcda8b9..35e86a5 100644 --- a/src/components/olmap/core/Controls/styleEditorPresets.ts +++ b/src/components/olmap/core/Controls/styleEditorPresets.ts @@ -83,7 +83,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record< styleConfig: { property: "pressure", classificationMethod: "custom_breaks", - customBreaks: [16, 18, 20, 22, 24, 26], + customBreaks: [16, 18, 20, 22, 24, 26, 28], customColors: [ "rgba(255, 0, 0, 1)", "rgba(255, 127, 0, 1)", @@ -137,7 +137,7 @@ const DEFAULT_LAYER_STYLE_PRESETS: Record< showId: false, opacity: 0.9, adjustWidthByProperty: true, - customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2], + customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4], customColors: [], }, legendConfig: { diff --git a/src/components/olmap/core/Controls/styleEditorTypes.ts b/src/components/olmap/core/Controls/styleEditorTypes.ts index a5eb539..f94b4d7 100644 --- a/src/components/olmap/core/Controls/styleEditorTypes.ts +++ b/src/components/olmap/core/Controls/styleEditorTypes.ts @@ -3,16 +3,20 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import { LegendStyleConfig } from "./StyleLegend"; +export type ClassificationMethod = "pretty_breaks" | "custom_breaks"; +export type ColorType = "single" | "gradient" | "rainbow" | "custom"; + export interface StyleConfig { property: string; - classificationMethod: string; + classificationMethod: ClassificationMethod; + /** Number of rendered intervals. Boundaries always contain segments + 1 values. */ segments: number; minSize: number; maxSize: number; minStrokeWidth: number; maxStrokeWidth: number; fixedStrokeWidth: number; - colorType: string; + colorType: ColorType; singlePaletteIndex: number; gradientPaletteIndex: number; rainbowPaletteIndex: number; @@ -24,6 +28,19 @@ export interface StyleConfig { customColors?: string[]; } +export interface ResolvedLayerStyle { + boundaries: number[]; + colors: string[]; + dimensions: number[]; + labels: string[]; + isConstant: boolean; +} + +export interface StyleValidationResult { + valid: boolean; + errors: string[]; +} + export interface LayerStyleState { layerId: string; layerName: string; @@ -37,6 +54,7 @@ export type DefaultLayerStyleId = "junctions" | "pipes"; export interface StyleEditorStateProps { layerStyleStates: LayerStyleState[]; setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>; + workspace: string; } export interface AvailableProperty { @@ -50,15 +68,17 @@ export interface StyleEditorFormProps { styleConfig: StyleConfig; setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>; availableProperties: AvailableProperty[]; - onLayerChange: (index: number) => void; + onLayerChange: (layerId: string) => void; onPropertyChange: (property: string) => void; - onClassificationMethodChange: (method: string) => void; + onClassificationMethodChange: (method: ClassificationMethod) => void; onSegmentsChange: (segments: number) => void; onCustomBreakChange: (index: number, value: string) => void; - onCustomBreakBlur: () => void; - onColorTypeChange: (colorType: string) => void; + onColorTypeChange: (colorType: ColorType) => void; onApply: () => void; onReset: () => void; + validationErrors: string[]; + isDirty: boolean; + isApplying: boolean; } export interface StyleEditorPanelProps extends StyleEditorFormProps { diff --git a/src/components/olmap/core/Controls/styleEditorUtils.test.ts b/src/components/olmap/core/Controls/styleEditorUtils.test.ts new file mode 100644 index 0000000..3237e56 --- /dev/null +++ b/src/components/olmap/core/Controls/styleEditorUtils.test.ts @@ -0,0 +1,107 @@ +import { createDefaultLayerStyleState } from "./styleEditorPresets"; +import { + buildDynamicStyleTemplate, + buildStyleVariables, + getDefaultCustomBreaks, + resolveLayerStyle, + requiresStyleApply, + validateStyleConfig, +} from "./styleEditorUtils"; + +describe("styleEditorUtils", () => { + it("uses N intervals, N+1 boundaries and N visual values", () => { + const styleConfig = { + ...createDefaultLayerStyleState("pipes").styleConfig, + classificationMethod: "pretty_breaks" as const, + segments: 4, + }; + const resolved = resolveLayerStyle({ + layerType: "linestring", + styleConfig, + values: [0, 1, 2, 3, 4, 5], + }); + + expect(resolved?.boundaries).toHaveLength(5); + expect(resolved?.colors).toHaveLength(4); + expect(resolved?.dimensions).toHaveLength(4); + expect(resolved?.labels).toHaveLength(4); + }); + + it("accepts signed custom boundaries and rejects unordered boundaries", () => { + const base = createDefaultLayerStyleState("junctions").styleConfig; + const valid = { + ...base, + segments: 3, + customBreaks: [-5, 0, 5, 10], + customColors: base.customColors?.slice(0, 3), + }; + expect(validateStyleConfig(valid)).toEqual({ valid: true, errors: [] }); + expect( + validateStyleConfig({ ...valid, customBreaks: [-5, 0, 0, 10] }).valid, + ).toBe(false); + }); + + it("creates deterministic defaults and collapses constant data labels", () => { + const defaults = getDefaultCustomBreaks({ + segments: 3, + property: "pressure", + layerId: "junctions", + currentJunctionCalData: [{ value: 20 }, { value: 20 }], + }); + expect(defaults).toHaveLength(4); + expect( + defaults.every( + (value, index) => index === 0 || value > defaults[index - 1], + ), + ).toBe(true); + + const styleConfig = { + ...createDefaultLayerStyleState("junctions").styleConfig, + classificationMethod: "pretty_breaks" as const, + segments: 3, + }; + const resolved = resolveLayerStyle({ + layerType: "point", + styleConfig, + values: [20, 20], + }); + expect(resolved?.isConstant).toBe(true); + expect(resolved?.labels).toEqual(["20"]); + }); + + it("generates variable-only visual templates with shader-safe names", () => { + const styleConfig = createDefaultLayerStyleState("pipes").styleConfig; + const resolved = resolveLayerStyle({ + layerType: "linestring", + styleConfig, + values: [], + }); + expect(resolved).not.toBeNull(); + const template = buildDynamicStyleTemplate({ + layerType: "linestring", + property: styleConfig.property, + classCount: styleConfig.segments, + }); + const variables = buildStyleVariables(styleConfig, resolved!); + const serialized = JSON.stringify({ template, variables }); + expect(serialized).toContain("tj_color_0"); + expect(serialized).not.toContain("__"); + }); + + it("requires Apply only for structural changes", () => { + const applied = createDefaultLayerStyleState("pipes").styleConfig; + expect(requiresStyleApply(applied, { ...applied, opacity: 0.4 })).toBe(false); + expect( + requiresStyleApply(applied, { ...applied, minStrokeWidth: 1 }), + ).toBe(false); + expect( + requiresStyleApply(applied, { ...applied, property: "flow" }), + ).toBe(true); + expect( + requiresStyleApply(applied, { + ...applied, + customBreaks: [...(applied.customBreaks || []), 2], + }), + ).toBe(true); + }); +}); diff --git a/src/components/olmap/core/Controls/styleEditorUtils.ts b/src/components/olmap/core/Controls/styleEditorUtils.ts index 0baf15b..6f59b49 100644 --- a/src/components/olmap/core/Controls/styleEditorUtils.ts +++ b/src/components/olmap/core/Controls/styleEditorUtils.ts @@ -1,4 +1,4 @@ -import { FlatStyleLike } from "ol/style/flat"; +import type { FlatStyleLike, StyleVariables } from "ol/style/flat"; import { calculateClassification } from "@utils/breaksClassification"; import { parseColor } from "@utils/parseColor"; @@ -8,16 +8,34 @@ import { RAINBOW_PALETTES, SINGLE_COLOR_PALETTES, } from "./styleEditorPresets"; -import { StyleConfig } from "./styleEditorTypes"; +import type { + ResolvedLayerStyle, + StyleConfig, + StyleValidationResult, +} from "./styleEditorTypes"; + +export const MIN_CLASS_COUNT = 2; +export const MAX_CLASS_COUNT = 10; + +const clampIndex = (value: number, length: number) => + Math.min(Math.max(Math.round(value) || 0, 0), Math.max(length - 1, 0)); + +const arraysEqual = <T>(left: readonly T[] = [], right: readonly T[] = []) => + left.length === right.length && left.every((value, index) => value === right[index]); + +const withOpacity = (color: string, opacity: number) => { + const parsed = parseColor(color); + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${opacity})`; +}; + +const formatBoundary = (value: number) => + Number.isInteger(value) ? String(value) : Number(value.toFixed(3)).toString(); export const rgbaToHex = (rgba: string) => { try { - const c = parseColor(rgba); - const toHex = (n: number) => { - const hex = Math.round(n).toString(16); - return hex.length === 1 ? `0${hex}` : hex; - }; - return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`; + const color = parseColor(rgba); + const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0"); + return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`; } catch { return "#000000"; } @@ -28,25 +46,71 @@ export const hexToRgba = (hex: string) => { return result ? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt( result[3], - 16 + 16, )}, 1)` : "rgba(0, 0, 0, 1)"; }; export const getDefaultCustomColors = ( segments: number, - existingColors: string[] = [] + existingColors: string[] = [], ) => { const nextColors = [...existingColors]; const baseColors = RAINBOW_PALETTES[0].colors; - while (nextColors.length < segments) { nextColors.push(baseColors[nextColors.length % baseColors.length]); } - return nextColors.slice(0, segments); }; +const createEqualBoundaries = (minimum: number, maximum: number, segments: number) => { + if (minimum === maximum) { + return Array.from({ length: segments + 1 }, () => minimum); + } + return Array.from( + { length: segments + 1 }, + (_, index) => minimum + ((maximum - minimum) * index) / segments, + ); +}; + +const normalizeCalculatedBoundaries = ( + calculated: number[], + values: number[], + segments: number, +) => { + const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b); + if (finiteValues.length === 0) return []; + const minimum = finiteValues[0]; + const maximum = finiteValues[finiteValues.length - 1]; + if (minimum === maximum) return createEqualBoundaries(minimum, maximum, segments); + + const candidates = Array.from( + new Set([minimum, ...calculated.filter(Number.isFinite), maximum]), + ) + .filter((value) => value >= minimum && value <= maximum) + .sort((a, b) => a - b); + + if (candidates.length === segments + 1) return candidates; + return createEqualBoundaries(minimum, maximum, segments); +}; + +export const resolveBoundaries = ( + values: number[], + styleConfig: StyleConfig, +): number[] => { + const segments = Math.min( + MAX_CLASS_COUNT, + Math.max(MIN_CLASS_COUNT, Math.round(styleConfig.segments)), + ); + if (styleConfig.classificationMethod === "custom_breaks") { + return [...(styleConfig.customBreaks || [])]; + } + const finiteValues = values.filter(Number.isFinite); + if (finiteValues.length === 0) return []; + const calculated = calculateClassification(finiteValues, segments, "pretty_breaks"); + return normalizeCalculatedBoundaries(calculated, finiteValues, segments); +}; + export const getDefaultCustomBreaks = ({ segments, property, @@ -64,261 +128,255 @@ export const getDefaultCustomBreaks = ({ currentJunctionCalData?: any[]; currentPipeCalData?: any[]; }) => { - if (!layerId || !property) { - return Array.from({ length: segments }, () => 0); + let values: number[] = []; + if (layerId === "junctions" && property === "elevation" && elevationRange) { + values = elevationRange; + } else if (layerId === "pipes" && property === "diameter" && diameterRange) { + values = diameterRange; + } else if (layerId === "junctions") { + values = (currentJunctionCalData || []).map((item: any) => Number(item.value)); + } else if (layerId === "pipes") { + values = (currentPipeCalData || []).map((item: any) => Number(item.value)); } - - let dataArr: number[] = []; - - const isElevation = layerId === "junctions" && property === "elevation"; - const isDiameter = layerId === "pipes" && property === "diameter"; - - if (isElevation && elevationRange) { - dataArr = [elevationRange[0], elevationRange[1]]; - } else if (isDiameter && diameterRange) { - dataArr = [diameterRange[0], diameterRange[1]]; - } else if (layerId === "junctions" && currentJunctionCalData) { - dataArr = currentJunctionCalData.map((d: any) => d.value); - } else if (layerId === "pipes" && currentPipeCalData) { - dataArr = currentPipeCalData.map((d: any) => d.value); + const finiteValues = values.filter(Number.isFinite); + if (!property || finiteValues.length === 0) { + return Array.from({ length: segments + 1 }, (_, index) => index); } - - if (dataArr.length === 0) { - return Array.from({ length: segments }, () => 0); + const minimum = Math.min(...finiteValues); + const maximum = Math.max(...finiteValues); + if (minimum === maximum) { + const padding = Math.max(Math.abs(minimum) * 0.01, 1); + return createEqualBoundaries(minimum - padding, maximum + padding, segments); } - - const defaultBreaks = calculateClassification( - dataArr, + return normalizeCalculatedBoundaries( + calculateClassification(finiteValues, segments, "pretty_breaks"), + finiteValues, segments, - "pretty_breaks" - ).slice(0, segments); - - while (defaultBreaks.length < segments) { - defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0); - } - - return defaultBreaks; -}; - -export const normalizeCustomBreaks = (breaks: number[], desired: number) => { - const nextBreaks = [...breaks] - .slice(0, desired) - .filter((value) => value >= 0) - .sort((a, b) => a - b); - - while (nextBreaks.length < desired) { - nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0); - } - - return nextBreaks; -}; - -export const addBreakExtrema = (breaks: number[], dataValues: number[]) => { - const nextBreaks = [...breaks]; - const minValue = Math.max( - dataValues.reduce((min, value) => Math.min(min, value), Infinity), - 0 ); - const maxValue = dataValues.reduce( - (max, value) => Math.max(max, value), - -Infinity - ); - - if (!nextBreaks.includes(minValue)) { - nextBreaks.push(minValue); - } - - if (!nextBreaks.includes(maxValue)) { - nextBreaks.push(maxValue); - } - - nextBreaks.sort((a, b) => a - b); - return nextBreaks; }; +export const normalizeCustomBreaks = (breaks: number[], segments: number) => { + const finite = breaks.filter(Number.isFinite).slice(0, segments + 1); + if (finite.length === segments + 1) return finite; + if (finite.length >= 2) { + return createEqualBoundaries(finite[0], finite[finite.length - 1], segments); + } + return Array.from({ length: segments + 1 }, (_, index) => finite[0] ?? index); +}; + +export const validateStyleConfig = (styleConfig: StyleConfig): StyleValidationResult => { + const errors: string[] = []; + if (!styleConfig.property) errors.push("请选择分级属性"); + if ( + !Number.isInteger(styleConfig.segments) || + styleConfig.segments < MIN_CLASS_COUNT || + styleConfig.segments > MAX_CLASS_COUNT + ) { + errors.push(`分类数量必须是 ${MIN_CLASS_COUNT}-${MAX_CLASS_COUNT} 的整数`); + } + if (styleConfig.classificationMethod === "custom_breaks") { + const boundaries = styleConfig.customBreaks || []; + if (boundaries.length !== styleConfig.segments + 1) { + errors.push(`需要 ${styleConfig.segments + 1} 个区间边界`); + } else if (boundaries.some((value) => !Number.isFinite(value))) { + errors.push("区间边界必须是有限数字"); + } else if (boundaries.some((value, index) => index > 0 && value <= boundaries[index - 1])) { + errors.push("区间边界必须严格递增"); + } + } + if (styleConfig.colorType === "custom") { + const colors = styleConfig.customColors || []; + if (colors.length !== styleConfig.segments) { + errors.push(`需要 ${styleConfig.segments} 个自定义颜色`); + } else if (colors.some((color) => { + try { + parseColor(color); + return false; + } catch { + return true; + } + })) { + errors.push("自定义颜色格式无效"); + } + } + if (!Number.isFinite(styleConfig.opacity) || styleConfig.opacity < 0 || styleConfig.opacity > 1) { + errors.push("透明度必须在 0 到 1 之间"); + } + const paletteIndexes: Array<[number, number]> = [ + [styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length], + [styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length], + [styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length], + ]; + if ( + paletteIndexes.some( + ([index, length]) => !Number.isInteger(index) || index < 0 || index >= length, + ) + ) { + errors.push("色板索引无效"); + } + if ( + [ + styleConfig.minSize, + styleConfig.maxSize, + styleConfig.minStrokeWidth, + styleConfig.maxStrokeWidth, + styleConfig.fixedStrokeWidth, + ].some((value) => !Number.isFinite(value) || value <= 0) + ) { + errors.push("符号尺寸必须大于 0"); + } + if (styleConfig.minSize > styleConfig.maxSize) errors.push("节点最小尺寸不能大于最大尺寸"); + if (styleConfig.minStrokeWidth > styleConfig.maxStrokeWidth) { + errors.push("管线最小宽度不能大于最大宽度"); + } + return { valid: errors.length === 0, errors }; +}; + +export const requiresStyleApply = ( + applied: StyleConfig | undefined, + draft: StyleConfig, +) => + !applied || + applied.property !== draft.property || + applied.classificationMethod !== draft.classificationMethod || + applied.segments !== draft.segments || + (draft.classificationMethod === "custom_breaks" && + !arraysEqual(applied.customBreaks, draft.customBreaks)); + export const resolveStyleColors = ( styleConfig: StyleConfig, - breaksLength: number + classCount = styleConfig.segments, ): string[] => { if (styleConfig.colorType === "single") { - return Array.from( - { length: breaksLength }, - () => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color - ); + const palette = SINGLE_COLOR_PALETTES[ + clampIndex(styleConfig.singlePaletteIndex, SINGLE_COLOR_PALETTES.length) + ]; + return Array.from({ length: classCount }, () => palette.color); } - if (styleConfig.colorType === "gradient") { - const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; - const startColor = parseColor(start); - const endColor = parseColor(end); - - return Array.from({ length: breaksLength }, (_, index) => { - const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1; - const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio); - const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio); - const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio); - return `rgba(${r}, ${g}, ${b}, 1)`; + const palette = GRADIENT_PALETTES[ + clampIndex(styleConfig.gradientPaletteIndex, GRADIENT_PALETTES.length) + ]; + const start = parseColor(palette.start); + const end = parseColor(palette.end); + return Array.from({ length: classCount }, (_, index) => { + const ratio = classCount > 1 ? index / (classCount - 1) : 0; + return `rgba(${Math.round(start.r + (end.r - start.r) * ratio)}, ${Math.round( + start.g + (end.g - start.g) * ratio, + )}, ${Math.round(start.b + (end.b - start.b) * ratio)}, 1)`; }); } - if (styleConfig.colorType === "rainbow") { - const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; + const palette = RAINBOW_PALETTES[ + clampIndex(styleConfig.rainbowPaletteIndex, RAINBOW_PALETTES.length) + ]; return Array.from( - { length: breaksLength }, - (_, index) => baseColors[index % baseColors.length] + { length: classCount }, + (_, index) => palette.colors[index % palette.colors.length], ); } - - const customColors = styleConfig.customColors || []; - const reverseRainbowColors = RAINBOW_PALETTES[1].colors; - const result = [...customColors]; - - while (result.length < breaksLength) { - result.push( - reverseRainbowColors[ - (result.length - customColors.length) % reverseRainbowColors.length - ] - ); - } - - return result.slice(0, breaksLength); -}; - -export const getSizePreviewColors = (styleConfig: StyleConfig) => { - if (styleConfig.colorType === "single") { - const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color; - return [color, color]; - } - - if (styleConfig.colorType === "gradient") { - const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex]; - return [start, end]; - } - - if (styleConfig.colorType === "rainbow") { - const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; - return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]]; - } - - const customColors = styleConfig.customColors || []; - return [ - customColors[0] || "rgba(0,0,0,1)", - customColors[customColors.length - 1] || "rgba(0,0,0,1)", - ]; + return getDefaultCustomColors(classCount, styleConfig.customColors || []); }; export const resolveDimensions = ({ layerType, styleConfig, - breaksLength, + classCount = styleConfig.segments, }: { layerType: string; styleConfig: StyleConfig; - breaksLength: number; + classCount?: number; }) => { + const interpolate = (minimum: number, maximum: number, index: number) => { + const ratio = classCount > 1 ? index / (classCount - 1) : 0; + return minimum + (maximum - minimum) * ratio; + }; if (layerType === "linestring") { - if (styleConfig.adjustWidthByProperty) { - return Array.from({ length: breaksLength }, (_, index) => { - const ratio = index / (breaksLength - 1); - return ( - styleConfig.minStrokeWidth + - (styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio - ); - }); - } - - return Array.from( - { length: breaksLength }, - () => styleConfig.fixedStrokeWidth + return Array.from({ length: classCount }, (_, index) => + styleConfig.adjustWidthByProperty + ? interpolate(styleConfig.minStrokeWidth, styleConfig.maxStrokeWidth, index) + : styleConfig.fixedStrokeWidth, ); } - - return Array.from({ length: breaksLength }, (_, index) => { - const ratio = index / (breaksLength - 1); - return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio; - }); + return Array.from({ length: classCount }, (_, index) => + interpolate(styleConfig.minSize, styleConfig.maxSize, index), + ); }; -export const buildDynamicStyle = ({ +export const resolveLayerStyle = ({ layerType, styleConfig, - breaks, - colors, - dimensions, + values, }: { layerType: string; styleConfig: StyleConfig; - breaks: number[]; - colors: string[]; - dimensions: number[]; -}): FlatStyleLike => { - const generateColorConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let index = 1; index < breaks.length; index++) { - if (property === "unit_headloss") { - conditions.push([ - "<=", - ["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]], - breaks[index], - ]); - } else { - conditions.push(["<=", ["get", property], breaks[index]]); - } - const colorObj = parseColor(colors[index - 1]); - conditions.push( - `rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})` + values: number[]; +}): ResolvedLayerStyle | null => { + const boundaries = resolveBoundaries(values, styleConfig); + if (boundaries.length !== styleConfig.segments + 1) return null; + const colors = resolveStyleColors(styleConfig, styleConfig.segments); + const dimensions = resolveDimensions({ layerType, styleConfig }); + const isConstant = boundaries.every((value) => value === boundaries[0]); + const labels = isConstant + ? [`${formatBoundary(boundaries[0])}`] + : Array.from( + { length: styleConfig.segments }, + (_, index) => `${formatBoundary(boundaries[index])} - ${formatBoundary(boundaries[index + 1])}`, ); - } - const defaultColor = parseColor(colors[0]); - conditions.push( - `rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})` + return { boundaries, colors, dimensions, labels, isConstant }; +}; + +const valueExpression = (property: string): any[] => ["get", property]; + +const buildVariableCase = (property: string, classCount: number, variablePrefix: string) => { + const expression: any[] = ["case"]; + for (let index = 0; index < classCount - 1; index += 1) { + expression.push( + ["<=", valueExpression(property), ["var", `tj_break_${index + 1}`]], + ["var", `${variablePrefix}_${index}`], ); - return conditions; - }; - - const generateDimensionConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let index = 0; index < breaks.length; index++) { - if (property === "unit_headloss") { - conditions.push([ - "<=", - ["/", ["get", "headloss"], ["get", "length"]], - breaks[index], - ]); - } else { - conditions.push(["<=", ["get", property], breaks[index]]); - } - conditions.push(dimensions[index]); - } - conditions.push(dimensions[dimensions.length - 1]); - return conditions; - }; - - const generatePointDimensionConditions = (property: string): any[] => { - const conditions: any[] = ["case"]; - for (let index = 0; index < breaks.length; index++) { - conditions.push(["<=", ["get", property], breaks[index]]); - conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]); - } - conditions.push(dimensions[dimensions.length - 1]); - return conditions; - }; - - const dynamicStyle: FlatStyleLike = {}; - - if (layerType === "linestring") { - dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property); - dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property); - } else if (layerType === "point") { - dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property); - dynamicStyle["circle-radius"] = generatePointDimensionConditions( - styleConfig.property - ); - dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property); - dynamicStyle["circle-stroke-width"] = 2; } + expression.push(["var", `${variablePrefix}_${classCount - 1}`]); + return expression; +}; - return dynamicStyle; +export const buildDynamicStyleTemplate = ({ + layerType, + property, + classCount, +}: { + layerType: string; + property: string; + classCount: number; +}): FlatStyleLike => { + const color = buildVariableCase(property, classCount, "tj_color"); + const dimension = buildVariableCase(property, classCount, "tj_size"); + if (layerType === "linestring") { + return { "stroke-color": color, "stroke-width": dimension }; + } + return { + "circle-fill-color": color, + "circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimension], + "circle-stroke-color": color, + "circle-stroke-width": 2, + }; +}; + +export const buildStyleVariables = ( + styleConfig: StyleConfig, + resolvedStyle: ResolvedLayerStyle, +): StyleVariables => { + const variables: StyleVariables = {}; + resolvedStyle.boundaries.slice(1, -1).forEach((boundary, index) => { + variables[`tj_break_${index + 1}`] = boundary; + }); + resolvedStyle.colors.forEach((color, index) => { + variables[`tj_color_${index}`] = withOpacity(color, styleConfig.opacity); + }); + resolvedStyle.dimensions.forEach((dimension, index) => { + variables[`tj_size_${index}`] = dimension; + }); + return variables; }; export const buildContourDefinitions = ({ @@ -329,20 +387,12 @@ export const buildContourDefinitions = ({ styleConfig: StyleConfig; breaks: number[]; colors: string[]; -}) => { - const contours = []; - for (let index = 0; index < breaks.length - 1; index++) { - const colorObj = parseColor(colors[index]); - contours.push({ +}) => + colors.map((color, index) => { + const parsed = parseColor(color); + return { threshold: [breaks[index], breaks[index + 1]], - color: [ - colorObj.r, - colorObj.g, - colorObj.b, - Math.round(styleConfig.opacity * 255), - ], + color: [parsed.r, parsed.g, parsed.b, Math.round(styleConfig.opacity * 255)], strokeWidth: 0, - }); - } - return contours; -}; + }; + }); diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index 65c4506..c708ce3 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -1,184 +1,245 @@ import { useNotification } from "@refinedev/core"; -import { VectorTile } from "ol"; import type { Map as OlMap } from "ol"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; -import VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike } from "ol/style/flat"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FlatStyleLike } from "ol/style/flat"; import { config } from "@/config/config"; +import { isLpsFlowProperty, toM3h } from "@utils/units"; +import { LayerStyleController } from "../layerStyleController"; import { useData, useMap } from "../MapComponent"; import { createDefaultLayerStyleState, + createDefaultLayerStyleStates, createEmptyStyleConfig, } from "./styleEditorPresets"; import { - addBreakExtrema, buildContourDefinitions, - buildDynamicStyle, + buildDynamicStyleTemplate, + buildStyleVariables, getDefaultCustomBreaks, getDefaultCustomColors, normalizeCustomBreaks, + requiresStyleApply, resolveDimensions, + resolveLayerStyle, resolveStyleColors, + validateStyleConfig, } from "./styleEditorUtils"; -import { +import type { AvailableProperty, + ClassificationMethod, + ColorType, DefaultLayerStyleId, LayerStyleState, + ResolvedLayerStyle, StyleConfig, StyleEditorStateProps, } from "./styleEditorTypes"; -import { LegendStyleConfig } from "./StyleLegend"; -import { calculateClassification } from "@utils/breaksClassification"; -import { isLpsFlowProperty, toM3h } from "@utils/units"; +import type { LegendStyleConfig } from "./StyleLegend"; const UNIT_HEADLOSS_RANGE: [number, number] = [0, 5]; +const STYLE_STORAGE_VERSION = 2; + +const cloneStyleConfig = (styleConfig: StyleConfig): StyleConfig => ({ + ...styleConfig, + customBreaks: [...(styleConfig.customBreaks || [])], + customColors: [...(styleConfig.customColors || [])], +}); + +const configsEqual = (left?: StyleConfig, right?: StyleConfig) => + Boolean(left && right && JSON.stringify(left) === JSON.stringify(right)); + +const hasSameTemplate = (left: StyleConfig, right: StyleConfig) => + left.property === right.property && left.segments === right.segments; const normalizeComputedStyleValue = (property: string, value: unknown) => { const numericValue = Number(value); - if (!Number.isFinite(numericValue)) { - return Number.NaN; - } - + if (!Number.isFinite(numericValue)) return Number.NaN; const displayValue = isLpsFlowProperty(property) ? toM3h(numericValue, "lps") : numericValue; - return property === "flow" ? Math.abs(displayValue) : displayValue; }; +const isDefaultLayerId = (value: unknown): value is DefaultLayerStyleId => + value === "junctions" || value === "pipes"; + +const createLegendConfig = ({ + layerId, + layerName, + property, + layerType, + resolved, +}: { + layerId: DefaultLayerStyleId; + layerName: string; + property: string; + layerType: string; + resolved: ResolvedLayerStyle; +}): LegendStyleConfig => ({ + layerId, + layerName, + property, + colors: resolved.isConstant ? resolved.colors.slice(0, 1) : resolved.colors, + type: layerType, + dimensions: resolved.isConstant + ? resolved.dimensions.slice(0, 1) + : resolved.dimensions, + breaks: resolved.boundaries, + labels: resolved.labels, +}); + +const resolveStyleFromLegend = ( + styleConfig: StyleConfig, + legendConfig: LegendStyleConfig, +): ResolvedLayerStyle | null => { + if (legendConfig.breaks.length !== styleConfig.segments + 1) return null; + return { + boundaries: legendConfig.breaks, + colors: resolveStyleColors(styleConfig), + dimensions: resolveDimensions({ layerType: legendConfig.type, styleConfig }), + labels: legendConfig.labels || [], + isConstant: legendConfig.breaks.every( + (value) => value === legendConfig.breaks[0], + ), + }; +}; + +const createRenderInputs = ( + styleConfig: StyleConfig, + resolved: ResolvedLayerStyle, +) => { + const variables = buildStyleVariables(styleConfig, resolved); + return { + variables, + signature: JSON.stringify({ styleConfig, variables }), + }; +}; + export const useStyleEditor = ({ layerStyleStates, setLayerStyleStates, + workspace, }: StyleEditorStateProps) => { const map = useMap(); const data = useData(); const { open } = useNotification(); - + const activeMaps = useMemo<OlMap[]>( + () => (data?.maps?.length ? data.maps : map ? [map] : []), + [data, map], + ); + const compareMap = data?.compareMap; const currentJunctionCalData = data?.currentJunctionCalData; const currentPipeCalData = data?.currentPipeCalData; const compareJunctionCalData = data?.compareJunctionCalData; const comparePipeCalData = data?.comparePipeCalData; - const compareMap = data?.compareMap; - const activeMaps = useMemo<OlMap[]>( - () => (data?.maps?.length ? data.maps : map ? [map] : []), - [data?.maps, map] - ); - const junctionText = data?.junctionText ?? ""; - const pipeText = data?.pipeText ?? ""; + const elevationRange = data?.elevationRange; + const diameterRange = data?.diameterRange; + const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0; + const setJunctionText = data?.setJunctionText; + const setPipeText = data?.setPipeText; const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; const setShowPipeTextLayer = data?.setShowPipeTextLayer; const setShowJunctionId = data?.setShowJunctionId; const setShowPipeId = data?.setShowPipeId; const setContourLayerAvailable = data?.setContourLayerAvailable; + const setContours = data?.setContours; const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable; const setShowWaterflowLayer = data?.setShowWaterflowLayer; - const setJunctionText = data?.setJunctionText; - const setPipeText = data?.setPipeText; - const setContours = data?.setContours; - const diameterRange = data?.diameterRange; - const elevationRange = data?.elevationRange; - const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0; - const [applyJunctionStyle, setApplyJunctionStyle] = useState(false); - const [applyPipeStyle, setApplyPipeStyle] = useState(false); - const [styleUpdateTrigger, setStyleUpdateTrigger] = useState(0); - const prevStyleUpdateTriggerRef = useRef(0); - const lastForceStyleAutoApplyVersionRef = useRef(0); - - const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]); - const [selectedRenderLayer, setSelectedRenderLayer] = + const renderLayers = useMemo( + () => + (map?.getAllLayers() || []).filter( + (layer): layer is WebGLVectorTileLayer => + layer instanceof WebGLVectorTileLayer && + (layer.get("value") === "junctions" || layer.get("value") === "pipes"), + ), + [map], + ); + const [selectedRenderLayerState, setSelectedRenderLayer] = useState<WebGLVectorTileLayer>(); - const [styleConfig, setStyleConfig] = useState(createEmptyStyleConfig); + const selectedRenderLayer = + renderLayers.find( + (layer) => layer.get("value") === selectedRenderLayerState?.get("value"), + ) || renderLayers.find((layer) => layer.get("value") === "junctions") || renderLayers[0]; + const [styleConfig, setStyleConfig] = useState(() => + createDefaultLayerStyleState("junctions").styleConfig, + ); + const [isApplying, setIsApplying] = useState(false); + const [persistenceReady, setPersistenceReady] = useState(false); const latestLayerStyleStatesRef = useRef(layerStyleStates); - - const tileLoadListenersRef = useRef< - Map<string, { source: VectorTileSource; listener: (event: any) => void }> - >(new Map()); + const controllersRef = useRef(new Map<string, LayerStyleController>()); + const renderInputsRef = useRef( + new Map< + string, + { records: readonly any[]; signature: string } + >(), + ); + const draftsRef = useRef(new Map<string, StyleConfig>()); + const renderRevisionRef = useRef(new Map<DefaultLayerStyleId, number>()); + const lastForceStyleAutoApplyVersionRef = useRef(0); useEffect(() => { latestLayerStyleStatesRef.current = layerStyleStates; }, [layerStyleStates]); - const upsertLayerStyleState = useCallback( - (newStyleState: LayerStyleState) => { - const existingState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === newStyleState.layerId - ); - if ( - existingState && - JSON.stringify(existingState.styleConfig) === - JSON.stringify(newStyleState.styleConfig) && - JSON.stringify(existingState.legendConfig) === - JSON.stringify(newStyleState.legendConfig) && - existingState.layerName === newStyleState.layerName && - existingState.isActive === newStyleState.isActive - ) { - return; - } - - setLayerStyleStates((prev) => { - const existingIndex = prev.findIndex( - (state) => state.layerId === newStyleState.layerId - ); - const nextStates = - existingIndex === -1 - ? [...prev, newStyleState] - : prev.map((state, index) => - index === existingIndex ? newStyleState : state - ); - latestLayerStyleStatesRef.current = nextStates; - return nextStates; - }); - }, - [setLayerStyleStates] - ); - - const removeLayerStyleState = useCallback( - (layerId: string) => { - setLayerStyleStates((prev) => { - const nextStates = prev.filter((state) => state.layerId !== layerId); - latestLayerStyleStatesRef.current = nextStates; - return nextStates; - }); - }, - [setLayerStyleStates] - ); - - const getRenderLayersById = useCallback( - (layerId: string) => - activeMaps.flatMap((targetMap) => - targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .filter( - (layer): layer is WebGLVectorTileLayer => - layer instanceof WebGLVectorTileLayer - ) - ), - [activeMaps] - ); - const getMapKey = useCallback((targetMap: OlMap, layerId: string) => { const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map"; return `${mapUid}:${layerId}`; }, []); - const getDataForMap = useCallback( + const getRenderLayersById = useCallback( + (layerId: string) => + activeMaps.flatMap((targetMap) => + targetMap + .getAllLayers() + .filter( + (layer): layer is WebGLVectorTileLayer => + layer instanceof WebGLVectorTileLayer && layer.get("value") === layerId, + ), + ), + [activeMaps], + ); + + const getLayerForMap = useCallback((targetMap: OlMap, layerId: string) => + targetMap + .getAllLayers() + .find( + (layer): layer is WebGLVectorTileLayer => + layer instanceof WebGLVectorTileLayer && layer.get("value") === layerId, + ), []); + + const getController = useCallback( (targetMap: OlMap, layerId: string) => { + const key = getMapKey(targetMap, layerId); + let controller = controllersRef.current.get(key); + if (controller) return controller; + const layer = getLayerForMap(targetMap, layerId); + if (!layer) return null; + controller = new LayerStyleController({ + layer, + map: targetMap, + defaultStyle: config.MAP_DEFAULT_STYLE as FlatStyleLike, + stateNamespace: targetMap === compareMap ? "compare" : "primary", + }); + controllersRef.current.set(key, controller); + return controller; + }, + [compareMap, getLayerForMap, getMapKey], + ); + + const getDataForMap = useCallback( + (targetMap: OlMap, layerId: DefaultLayerStyleId) => { if (layerId === "junctions") { return targetMap === compareMap ? compareJunctionCalData || [] : currentJunctionCalData || []; } - if (layerId === "pipes") { - return targetMap === compareMap - ? comparePipeCalData || [] - : currentPipeCalData || []; - } - return []; + return targetMap === compareMap + ? comparePipeCalData || [] + : currentPipeCalData || []; }, [ compareJunctionCalData, @@ -186,18 +247,36 @@ export const useStyleEditor = ({ comparePipeCalData, currentJunctionCalData, currentPipeCalData, - ] + ], ); - const availableProperties = useMemo<AvailableProperty[]>(() => { - if (!selectedRenderLayer) { - return []; - } + const availableProperties = useMemo<AvailableProperty[]>( + () => (selectedRenderLayer?.get("properties") || []) as AvailableProperty[], + [selectedRenderLayer], + ); - return (selectedRenderLayer.get("properties") || []) as AvailableProperty[]; - }, [selectedRenderLayer]); + const upsertLayerStyleState = useCallback( + (nextState: LayerStyleState) => { + setLayerStyleStates((previous) => { + const index = previous.findIndex((state) => state.layerId === nextState.layerId); + if (index >= 0) { + const current = previous[index]; + if (JSON.stringify(current) === JSON.stringify(nextState)) return previous; + } + const next = + index < 0 + ? [...previous, nextState] + : previous.map((state, stateIndex) => + stateIndex === index ? nextState : state, + ); + latestLayerStyleStatesRef.current = next; + return next; + }); + }, + [setLayerStyleStates], + ); - const getBreakDefaults = useCallback( + const getDefaultBreaks = useCallback( (segments: number, property: string, layer = selectedRenderLayer) => getDefaultCustomBreaks({ segments, @@ -214,644 +293,47 @@ export const useStyleEditor = ({ diameterRange, elevationRange, selectedRenderLayer, - ] + ], ); - const saveLayerStyle = useCallback( - ( - layerId?: string, - newLegendConfig?: LegendStyleConfig, - overrideStyleConfig = styleConfig - ) => { - if (!overrideStyleConfig.property || !layerId) { - return; + const getClassificationValues = useCallback( + (layerId: DefaultLayerStyleId, property: string) => { + if (layerId === "junctions" && property === "elevation" && elevationRange) { + return [...elevationRange]; } - - const layerName = - newLegendConfig?.layerName || - selectedRenderLayer?.get("name") || - `图层${layerId}`; - const property = availableProperties.find( - (item) => item.value === overrideStyleConfig.property - ); - - const legendConfig: LegendStyleConfig = newLegendConfig || { - layerId, - layerName, - property: property?.name || overrideStyleConfig.property, - colors: [], - type: selectedRenderLayer?.get("type") || "point", - dimensions: [], - breaks: [], - }; - - const newStyleState: LayerStyleState = { - layerId, - layerName, - styleConfig: { ...overrideStyleConfig }, - legendConfig: { ...legendConfig }, - isActive: true, - }; - - upsertLayerStyleState(newStyleState); + if (layerId === "pipes" && property === "diameter" && diameterRange) { + return [...diameterRange]; + } + if (layerId === "pipes" && property === "unit_headloss") { + return [...UNIT_HEADLOSS_RANGE]; + } + const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData; + return (records || []) + .map((item: any) => normalizeComputedStyleValue(property, item.value)) + .filter(Number.isFinite); }, - [availableProperties, selectedRenderLayer, styleConfig, upsertLayerStyleState] + [currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange], ); - const applyContourLayerStyle = useCallback( - (layerStyleConfig: LayerStyleState, breaks?: number[]) => { - if (!breaks || breaks.length === 0 || !setContours) { - return; - } - - const colors = resolveStyleColors(layerStyleConfig.styleConfig, breaks.length); - setContours( - buildContourDefinitions({ - styleConfig: layerStyleConfig.styleConfig, - breaks, - colors, - }) - ); - }, - [setContours] - ); - - const applyLayerStyle = useCallback( - (layerStyleConfig: LayerStyleState, breaks?: number[]) => { - if (!breaks || breaks.length === 0) { - return; - } - - const nextStyleConfig = layerStyleConfig.styleConfig; - const targetLayers = getRenderLayersById(layerStyleConfig.layerId); - const renderLayer = targetLayers[0]; - if (!renderLayer || !nextStyleConfig.property) { - return; - } - - const layerType = renderLayer.get("type") as string; - const colors = resolveStyleColors(nextStyleConfig, breaks.length); - const dimensions = resolveDimensions({ - layerType, - styleConfig: nextStyleConfig, - breaksLength: breaks.length, - }); - const dynamicStyle = buildDynamicStyle({ - layerType, - styleConfig: nextStyleConfig, - breaks, - colors, - dimensions, - }); - - targetLayers.forEach((targetLayer) => { - targetLayer.setStyle(dynamicStyle); - }); - - const layerId = renderLayer.get("value"); - const initLayerStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === layerId - ); - const legendConfig: LegendStyleConfig = { - layerName: initLayerStyleState?.layerName || `图层${layerId}`, - layerId, - property: initLayerStyleState?.legendConfig.property || "", - colors, - type: layerType, - dimensions, - breaks, - }; - - setTimeout(() => { - saveLayerStyle(layerId, legendConfig, nextStyleConfig); - }, 100); - }, - [getRenderLayersById, saveLayerStyle] - ); - - const applyClassificationStyle = useCallback( - (layerType: "junctions" | "pipes", fallbackStyleConfig?: LayerStyleState["styleConfig"]) => { - const layerStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === layerType - ); - const effectiveStyleConfig = layerStyleState?.styleConfig || fallbackStyleConfig; - - if (!effectiveStyleConfig) { - return; - } - - const isElevation = - layerType === "junctions" && effectiveStyleConfig.property === "elevation"; - const isDiameter = - layerType === "pipes" && effectiveStyleConfig.property === "diameter"; - const isUnitHeadloss = - layerType === "pipes" && effectiveStyleConfig.property === "unit_headloss"; - - const dataValues = - layerType === "junctions" - ? isElevation && elevationRange - ? [elevationRange[0], elevationRange[1]] - : currentJunctionCalData - ?.map((item: any) => - normalizeComputedStyleValue(effectiveStyleConfig.property, item.value) - ) - .filter(Number.isFinite) || [] - : isDiameter && diameterRange - ? [diameterRange[0], diameterRange[1]] - : isUnitHeadloss - ? [UNIT_HEADLOSS_RANGE[0], UNIT_HEADLOSS_RANGE[1]] - : currentPipeCalData - ?.map((item: any) => - normalizeComputedStyleValue(effectiveStyleConfig.property, item.value) - ) - .filter(Number.isFinite) || []; - - const canApply = - layerType === "junctions" - ? dataValues.length > 0 - : dataValues.length > 0 || isUnitHeadloss; - - if (!canApply || dataValues.length === 0) { - return; - } - - const segments = effectiveStyleConfig.segments ?? 5; - let breaks = - effectiveStyleConfig.classificationMethod === "custom_breaks" - ? normalizeCustomBreaks(effectiveStyleConfig.customBreaks || [], segments) - : calculateClassification( - dataValues, - segments, - effectiveStyleConfig.classificationMethod - ); - - if (breaks.length === 0) { - return; - } - - breaks = addBreakExtrema(breaks, dataValues); - - const styleStateToApply = - layerStyleState || - ({ - layerId: layerType, - layerName: layerType === "junctions" ? "节点" : "管道", - styleConfig: effectiveStyleConfig, - legendConfig: { - layerId: layerType, - layerName: layerType === "junctions" ? "节点" : "管道", - property: effectiveStyleConfig.property, - colors: [], - type: layerType === "junctions" ? "point" : "linestring", - dimensions: [], - breaks: [], - }, - isActive: true, - } as LayerStyleState); - - applyLayerStyle(styleStateToApply, breaks); - if (layerType === "junctions") { - applyContourLayerStyle(styleStateToApply, breaks); - } - }, - [ - applyContourLayerStyle, - applyLayerStyle, - currentJunctionCalData, - currentPipeCalData, - diameterRange, - elevationRange, - ] - ); - - const updateVectorTileSource = useCallback( - (targetMap: OlMap, layerId: string, property: string, records: any[]) => { - const vectorTileSources = targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source); - - if (!vectorTileSources.length) { - return; - } - - const dataMap = new Map<string, number>(); - records.forEach((record: any) => { - dataMap.set(record.ID, normalizeComputedStyleValue(property, record.value || 0)); - }); - - vectorTileSources.forEach((vectorTileSource) => { - const sourceTiles = (vectorTileSource as any).sourceTiles_; - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) { - return; - } - - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = dataMap.get(featureId); - if (value === undefined) { - return; - } - - renderFeature.properties_[property] = value; - }); - }); - }); - }, - [] - ); - - const attachVectorTileSourceLoadedEvent = useCallback( - (targetMap: OlMap, layerId: string, property: string, records: any[]) => { - const vectorTileSource = targetMap - .getAllLayers() - .filter((layer) => layer.get("value") === layerId) - .map((layer) => layer.getSource() as VectorTileSource) - .filter((source) => source)[0]; - - if (!vectorTileSource) { - return; - } - - const dataMap = new Map<string, number>(); - records.forEach((record: any) => { - dataMap.set(record.ID, normalizeComputedStyleValue(property, record.value || 0)); - }); - - const listener = (event: any) => { - try { - if (!(event.tile instanceof VectorTile)) { - return; - } - - const renderFeatures = event.tile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) { - return; - } - - renderFeatures.forEach((renderFeature: any) => { - const featureId = renderFeature.get("id"); - const value = dataMap.get(featureId); - if (value === undefined) { - return; - } - - renderFeature.properties_[property] = value; - }); - } catch (error) { - console.error("Error processing tile load event:", error); - } - }; - - const listenerKey = getMapKey(targetMap, layerId); - vectorTileSource.on("tileloadend", listener); - tileLoadListenersRef.current.set(listenerKey, { - source: vectorTileSource, - listener, - }); - }, - [getMapKey] - ); - - const removeVectorTileSourceLoadedEvent = useCallback( - (targetMap: OlMap, layerId: string) => { - const listenerKey = getMapKey(targetMap, layerId); - const listenerState = tileLoadListenersRef.current.get(listenerKey); - if (listenerState) { - listenerState.source.un("tileloadend", listenerState.listener); - tileLoadListenersRef.current.delete(listenerKey); - } - }, - [getMapKey] - ); - - const handleApply = useCallback(() => { - if (!selectedRenderLayer || !styleConfig.property) { - return; - } - - const layerId = selectedRenderLayer.get("value"); - const property = styleConfig.property; - - if (styleConfig.classificationMethod === "custom_breaks") { - const expected = styleConfig.segments; - const custom = styleConfig.customBreaks || []; - - if ( - custom.length !== expected || - custom.some((value) => value === undefined || value === null || isNaN(value)) - ) { - open?.({ - type: "error", - message: `请设置 ${expected} 个有效的自定义阈值(数字)`, - }); - return; - } - - if (custom.some((value) => value < 0)) { - open?.({ type: "error", message: "自定义阈值必须大于等于 0" }); - return; - } - - setStyleConfig((prev) => ({ - ...prev, - customBreaks: [...(prev.customBreaks || [])] - .slice(0, expected) - .sort((a, b) => a - b), - })); - } - - if (layerId === "junctions") { - setJunctionText?.(property); - setShowJunctionTextLayer?.(styleConfig.showLabels); - setShowJunctionId?.(styleConfig.showId); - setApplyJunctionStyle(true); - setContourLayerAvailable?.(property === "pressure"); - saveLayerStyle(layerId); - open?.({ - type: "success", - message: "节点图层样式设置成功,等待数据更新。", - }); - } - - if (layerId === "pipes") { - const isFlowProperty = property === "flow"; - setPipeText?.(property); - setShowPipeTextLayer?.(styleConfig.showLabels); - setShowPipeId?.(styleConfig.showId); - setApplyPipeStyle(true); - setWaterflowLayerAvailable?.(isFlowProperty); - if (!isFlowProperty) { - setShowWaterflowLayer?.(false); - } - saveLayerStyle(layerId); - open?.({ - type: "success", - message: "管道图层样式设置成功,等待数据更新。", - }); - } - - setStyleUpdateTrigger((prev) => prev + 1); - }, [ - open, - saveLayerStyle, - selectedRenderLayer, - setContourLayerAvailable, - setJunctionText, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setShowWaterflowLayer, - setWaterflowLayerAvailable, - styleConfig, - ]); - - const handleReset = useCallback(() => { - if (!selectedRenderLayer) { - return; - } - - const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; - const layerId = selectedRenderLayer.get("value"); - - getRenderLayersById(layerId).forEach((targetLayer) => { - targetLayer.setStyle(defaultFlatStyle); - }); - - removeLayerStyleState(layerId); - - if (layerId === "junctions") { - setApplyJunctionStyle(false); - setShowJunctionTextLayer?.(false); - setShowJunctionId?.(false); - setJunctionText?.(""); - setContours?.([]); - setContourLayerAvailable?.(false); - } else if (layerId === "pipes") { - setApplyPipeStyle(false); - setShowPipeTextLayer?.(false); - setShowPipeId?.(false); - setPipeText?.(""); - setWaterflowLayerAvailable?.(false); - } - }, [ - getRenderLayersById, - selectedRenderLayer, - setContourLayerAvailable, - setContours, - setJunctionText, - removeLayerStyleState, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setWaterflowLayerAvailable, - ]); - - const normalizeExternalStyleConfig = useCallback( - (layerId: DefaultLayerStyleId, overrides?: Partial<StyleConfig>): StyleConfig => { - const currentStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === layerId - ); - const baseStyleConfig = - currentStyleState?.styleConfig || createDefaultLayerStyleState(layerId).styleConfig; - const nextStyleConfig: StyleConfig = { - ...baseStyleConfig, - ...overrides, - customBreaks: overrides?.customBreaks - ? [...overrides.customBreaks] - : [...(baseStyleConfig.customBreaks || [])], - customColors: overrides?.customColors - ? [...overrides.customColors] - : [...(baseStyleConfig.customColors || [])], - }; - - nextStyleConfig.segments = Math.max(1, Math.round(nextStyleConfig.segments || 1)); - nextStyleConfig.opacity = Math.min(1, Math.max(0, nextStyleConfig.opacity)); - nextStyleConfig.singlePaletteIndex = Math.max( - 0, - Math.round(nextStyleConfig.singlePaletteIndex || 0) - ); - nextStyleConfig.gradientPaletteIndex = Math.max( - 0, - Math.round(nextStyleConfig.gradientPaletteIndex || 0) - ); - nextStyleConfig.rainbowPaletteIndex = Math.max( - 0, - Math.round(nextStyleConfig.rainbowPaletteIndex || 0) - ); - nextStyleConfig.minSize = Math.max(1, nextStyleConfig.minSize); - nextStyleConfig.maxSize = Math.max(nextStyleConfig.minSize, nextStyleConfig.maxSize); - nextStyleConfig.minStrokeWidth = Math.max(1, nextStyleConfig.minStrokeWidth); - nextStyleConfig.maxStrokeWidth = Math.max( - nextStyleConfig.minStrokeWidth, - nextStyleConfig.maxStrokeWidth - ); - nextStyleConfig.fixedStrokeWidth = Math.max(1, nextStyleConfig.fixedStrokeWidth); - nextStyleConfig.customColors = - nextStyleConfig.colorType === "custom" - ? getDefaultCustomColors( - nextStyleConfig.segments, - nextStyleConfig.customColors || [] - ) - : nextStyleConfig.customColors; - nextStyleConfig.customBreaks = - nextStyleConfig.classificationMethod === "custom_breaks" - ? normalizeCustomBreaks( - nextStyleConfig.customBreaks || - getBreakDefaults( - nextStyleConfig.segments, - nextStyleConfig.property, - getRenderLayersById(layerId)[0] - ), - nextStyleConfig.segments - ) - : nextStyleConfig.customBreaks; - - return nextStyleConfig; - }, - [getBreakDefaults, getRenderLayersById] - ); - - const applyExternalStyle = useCallback( - (layerId: DefaultLayerStyleId, overrides?: Partial<StyleConfig>) => { - const targetLayer = getRenderLayersById(layerId)[0]; - if (!targetLayer) { - open?.({ - type: "error", - message: `未找到${layerId === "junctions" ? "节点" : "管道"}图层,无法应用样式。`, - }); - return; - } - - const nextStyleConfig = normalizeExternalStyleConfig(layerId, overrides); - if (!nextStyleConfig.property) { - open?.({ - type: "error", - message: "样式工具缺少有效的渲染属性,无法应用样式。", - }); - return; - } - - const layerName = targetLayer.get("name") || (layerId === "junctions" ? "节点" : "管道"); - const targetProperties = (targetLayer.get("properties") || []) as AvailableProperty[]; - const propertyLabel = - targetProperties.find((item) => item.value === nextStyleConfig.property)?.name || - nextStyleConfig.property; - - setSelectedRenderLayer(targetLayer); - setStyleConfig(nextStyleConfig); - upsertLayerStyleState({ - layerId, - layerName, - styleConfig: nextStyleConfig, - legendConfig: { - layerId, - layerName, - property: propertyLabel, - colors: [], - type: targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"), - dimensions: [], - breaks: [], - }, - isActive: true, - }); - + const syncAuxiliaryLayers = useCallback( + (layerId: DefaultLayerStyleId, nextConfig: StyleConfig | null) => { + const active = Boolean(nextConfig); if (layerId === "junctions") { - setJunctionText?.(nextStyleConfig.property); - setShowJunctionTextLayer?.(nextStyleConfig.showLabels); - setShowJunctionId?.(nextStyleConfig.showId); - setContourLayerAvailable?.(nextStyleConfig.property === "pressure"); - setApplyJunctionStyle(true); - } else { - const isFlowProperty = nextStyleConfig.property === "flow"; - setPipeText?.(nextStyleConfig.property); - setShowPipeTextLayer?.(nextStyleConfig.showLabels); - setShowPipeId?.(nextStyleConfig.showId); - setWaterflowLayerAvailable?.(isFlowProperty); - if (!isFlowProperty) { - setShowWaterflowLayer?.(false); - } - setApplyPipeStyle(true); - } - - applyClassificationStyle(layerId, nextStyleConfig); - open?.({ - type: "success", - message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已应用。`, - }); - }, - [ - applyClassificationStyle, - getRenderLayersById, - normalizeExternalStyleConfig, - open, - setContourLayerAvailable, - setJunctionText, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setShowWaterflowLayer, - setWaterflowLayerAvailable, - upsertLayerStyleState, - ] - ); - - const resetExternalStyle = useCallback( - (layerId: DefaultLayerStyleId) => { - const targetLayer = getRenderLayersById(layerId)[0]; - if (!targetLayer) { - open?.({ - type: "error", - message: `未找到${layerId === "junctions" ? "节点" : "管道"}图层,无法重置样式。`, - }); + setJunctionText?.(nextConfig?.property || ""); + setShowJunctionTextLayer?.(active && Boolean(nextConfig?.showLabels)); + setShowJunctionId?.(active && Boolean(nextConfig?.showId)); + setContourLayerAvailable?.(active && nextConfig?.property === "pressure"); + if (!active) setContours?.([]); return; } - - const defaultStyleConfig = createDefaultLayerStyleState(layerId).styleConfig; - const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; - - setSelectedRenderLayer(targetLayer); - setStyleConfig(defaultStyleConfig); - - getRenderLayersById(layerId).forEach((renderLayer) => { - renderLayer.setStyle(defaultFlatStyle); - }); - - removeLayerStyleState(layerId); - - if (layerId === "junctions") { - setApplyJunctionStyle(false); - setShowJunctionTextLayer?.(false); - setShowJunctionId?.(false); - setJunctionText?.(""); - setContours?.([]); - setContourLayerAvailable?.(false); - } else { - setApplyPipeStyle(false); - setShowPipeTextLayer?.(false); - setShowPipeId?.(false); - setPipeText?.(""); - setWaterflowLayerAvailable?.(false); - } - - open?.({ - type: "success", - message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已重置。`, - }); + const isFlow = active && nextConfig?.property === "flow"; + setPipeText?.(nextConfig?.property || ""); + setShowPipeTextLayer?.(active && Boolean(nextConfig?.showLabels)); + setShowPipeId?.(active && Boolean(nextConfig?.showId)); + setWaterflowLayerAvailable?.(isFlow); + if (!isFlow) setShowWaterflowLayer?.(false); }, [ - getRenderLayersById, - open, - removeLayerStyleState, setContourLayerAvailable, setContours, setJunctionText, @@ -860,336 +342,605 @@ export const useStyleEditor = ({ setShowJunctionTextLayer, setShowPipeId, setShowPipeTextLayer, + setShowWaterflowLayer, setWaterflowLayerAvailable, - ] + ], ); - const handleLayerChange = useCallback( - (index: number) => { - const newLayer = index >= 0 ? renderLayers[index] : undefined; - setSelectedRenderLayer(newLayer); - - if (!newLayer) { - return; - } - - const layerId = newLayer.get("value"); - const cachedStyleState = layerStyleStates.find((state) => state.layerId === layerId); - - if (cachedStyleState) { - setStyleConfig(cachedStyleState.styleConfig); - return; - } - - setStyleConfig((prev) => ({ - ...prev, - property: "", - customBreaks: - prev.classificationMethod === "custom_breaks" - ? getBreakDefaults(prev.segments, "", newLayer) - : prev.customBreaks, - customColors: getDefaultCustomColors(prev.segments, prev.customColors), - })); + const syncContoursForStyle = useCallback( + ( + layerId: DefaultLayerStyleId, + nextConfig: StyleConfig, + resolved: ResolvedLayerStyle, + ) => { + if (layerId !== "junctions") return; + setContours?.( + nextConfig.property === "pressure" + ? buildContourDefinitions({ + styleConfig: nextConfig, + breaks: resolved.boundaries, + colors: resolved.colors, + }) + : [], + ); }, - [getBreakDefaults, layerStyleStates, renderLayers] + [setContours], + ); + + const renderAppliedStyle = useCallback( + async (layerId: DefaultLayerStyleId, nextConfig: StyleConfig) => { + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) return false; + const values = getClassificationValues(layerId, nextConfig.property); + const resolved = resolveLayerStyle({ + layerType: targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"), + styleConfig: nextConfig, + values, + }); + if (!resolved) return false; + + const revision = (renderRevisionRef.current.get(layerId) || 0) + 1; + renderRevisionRef.current.set(layerId, revision); + setIsApplying(true); + const layerType = targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"); + const template = buildDynamicStyleTemplate({ + layerType, + property: nextConfig.property, + classCount: nextConfig.segments, + }); + const { variables, signature: renderSignature } = createRenderInputs( + nextConfig, + resolved, + ); + const usesNativeProperty = + nextConfig.property === "elevation" || nextConfig.property === "diameter"; + + const commits = activeMaps.map(async (targetMap) => { + const key = getMapKey(targetMap, layerId); + const records = getDataForMap(targetMap, layerId); + const previousInput = renderInputsRef.current.get(key); + if ( + controllersRef.current.has(key) && + previousInput?.records === records && + previousInput.signature === renderSignature + ) { + return; + } + const controller = getController(targetMap, layerId); + if (!controller) return; + const options = { + property: nextConfig.property, + classCount: nextConfig.segments, + template, + variables, + }; + if (usesNativeProperty) { + controller.applyNative(options); + renderInputsRef.current.set(key, { records, signature: renderSignature }); + return; + } + const stateById = new Map<string, number>(); + records.forEach((record: any) => { + const id = record.ID ?? record.id; + if (id === undefined || id === null) return; + const value = normalizeComputedStyleValue(nextConfig.property, record.value); + if (Number.isFinite(value)) stateById.set(String(id), value); + }); + const committed = await controller.applyRuntime(options, stateById); + if (committed !== false) { + renderInputsRef.current.set(key, { records, signature: renderSignature }); + } + }); + await Promise.all(commits); + if (revision !== renderRevisionRef.current.get(layerId)) return false; + + const layerName = targetLayer.get("name") || (layerId === "junctions" ? "节点" : "管道"); + const properties = (targetLayer.get("properties") || []) as AvailableProperty[]; + const propertyLabel = + properties.find((item) => item.value === nextConfig.property)?.name || nextConfig.property; + const legendConfig = createLegendConfig({ + layerId, + layerName, + property: propertyLabel, + layerType, + resolved, + }); + upsertLayerStyleState({ + layerId, + layerName, + styleConfig: cloneStyleConfig(nextConfig), + legendConfig, + isActive: true, + }); + syncContoursForStyle(layerId, nextConfig, resolved); + setIsApplying(false); + return true; + }, + [ + activeMaps, + getClassificationValues, + getController, + getDataForMap, + getMapKey, + getRenderLayersById, + syncContoursForStyle, + upsertLayerStyleState, + ], + ); + + const activateStyle = useCallback( + async ( + layerId: DefaultLayerStyleId, + nextConfig: StyleConfig, + notify = true, + ) => { + const validation = validateStyleConfig(nextConfig); + if (!validation.valid) { + if (notify) open?.({ type: "error", message: validation.errors[0] }); + return false; + } + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) { + if (notify) open?.({ type: "error", message: "未找到目标图层,无法应用样式。" }); + return false; + } + const previous = latestLayerStyleStatesRef.current.find((state) => state.layerId === layerId); + const layerName = targetLayer.get("name") || (layerId === "junctions" ? "节点" : "管道"); + const layerType = targetLayer.get("type") || (layerId === "junctions" ? "point" : "linestring"); + const canUpdateVariablesOnly = + previous?.isActive && + hasSameTemplate(previous.styleConfig, nextConfig) && + activeMaps.every((targetMap) => { + const controller = controllersRef.current.get(getMapKey(targetMap, layerId)); + return controller?.hasTemplate(); + }); + + if (canUpdateVariablesOnly) { + const resolved = resolveLayerStyle({ + layerType, + styleConfig: nextConfig, + values: getClassificationValues(layerId, nextConfig.property), + }); + if (resolved) { + const { variables } = createRenderInputs(nextConfig, resolved); + activeMaps.forEach((targetMap) => + controllersRef.current + .get(getMapKey(targetMap, layerId)) + ?.updateVariables(variables), + ); + const properties = (targetLayer.get("properties") || []) as AvailableProperty[]; + const propertyLabel = + properties.find((item) => item.value === nextConfig.property)?.name || + nextConfig.property; + upsertLayerStyleState({ + layerId, + layerName, + styleConfig: cloneStyleConfig(nextConfig), + legendConfig: createLegendConfig({ + layerId, + layerName, + property: propertyLabel, + layerType, + resolved, + }), + isActive: true, + }); + syncContoursForStyle(layerId, nextConfig, resolved); + draftsRef.current.set(layerId, cloneStyleConfig(nextConfig)); + syncAuxiliaryLayers(layerId, nextConfig); + if (notify) { + open?.({ + type: "success", + message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已应用。`, + }); + } + return true; + } + } + upsertLayerStyleState({ + layerId, + layerName, + styleConfig: cloneStyleConfig(nextConfig), + legendConfig: previous?.legendConfig || { + layerId, + layerName, + property: nextConfig.property, + colors: [], + type: layerType, + dimensions: [], + breaks: [], + }, + isActive: true, + }); + draftsRef.current.set(layerId, cloneStyleConfig(nextConfig)); + syncAuxiliaryLayers(layerId, nextConfig); + const rendered = await renderAppliedStyle(layerId, nextConfig); + if (notify) { + open?.({ + type: rendered ? "success" : "progress", + message: rendered + ? `${layerId === "junctions" ? "节点" : "管道"}图层样式已应用。` + : "样式已保存,将在数据就绪后渲染。", + }); + } + return true; + }, + [ + activeMaps, + getClassificationValues, + getMapKey, + getRenderLayersById, + open, + renderAppliedStyle, + syncContoursForStyle, + syncAuxiliaryLayers, + upsertLayerStyleState, + ], + ); + + const resetLayer = useCallback( + (layerId: DefaultLayerStyleId, notify = true) => { + activeMaps.forEach((targetMap) => { + const key = getMapKey(targetMap, layerId); + controllersRef.current.get(key)?.reset(); + controllersRef.current.delete(key); + renderInputsRef.current.delete(key); + }); + const defaults = createDefaultLayerStyleState(layerId); + setLayerStyleStates((previous) => { + const next = previous.map((state) => (state.layerId === layerId ? defaults : state)); + latestLayerStyleStatesRef.current = next; + return next; + }); + draftsRef.current.delete(layerId); + syncAuxiliaryLayers(layerId, null); + if (selectedRenderLayer?.get("value") === layerId) { + setStyleConfig(cloneStyleConfig(defaults.styleConfig)); + } + if (notify) { + open?.({ + type: "success", + message: `${layerId === "junctions" ? "节点" : "管道"}图层样式已重置。`, + }); + } + }, + [activeMaps, getMapKey, open, selectedRenderLayer, setLayerStyleStates, syncAuxiliaryLayers], + ); + + const normalizeExternalStyleConfig = useCallback( + (layerId: DefaultLayerStyleId, overrides?: Partial<StyleConfig>) => { + const applied = latestLayerStyleStatesRef.current.find((state) => state.layerId === layerId); + const base = applied?.styleConfig || createDefaultLayerStyleState(layerId).styleConfig; + const next: StyleConfig = { + ...base, + ...overrides, + customBreaks: overrides?.customBreaks + ? [...overrides.customBreaks] + : [...(base.customBreaks || [])], + customColors: overrides?.customColors + ? [...overrides.customColors] + : [...(base.customColors || [])], + }; + const classCountChanged = + overrides?.segments !== undefined && overrides.segments !== base.segments; + if ( + next.colorType === "custom" && + overrides?.customColors === undefined && + (classCountChanged || base.colorType !== "custom") + ) { + next.customColors = getDefaultCustomColors(next.segments, next.customColors); + } + if ( + next.classificationMethod === "custom_breaks" && + overrides?.customBreaks === undefined && + (classCountChanged || base.classificationMethod !== "custom_breaks") + ) { + const fallback = getDefaultBreaks( + next.segments, + next.property, + getRenderLayersById(layerId)[0], + ); + next.customBreaks = normalizeCustomBreaks(fallback, next.segments); + } + return next; + }, + [getDefaultBreaks, getRenderLayersById], + ); + + const applyExternalStyle = useCallback( + (layerId: DefaultLayerStyleId, overrides?: Partial<StyleConfig>) => { + const targetLayer = getRenderLayersById(layerId)[0]; + if (!targetLayer) { + open?.({ type: "error", message: "未找到目标图层,无法应用样式。" }); + return; + } + const next = normalizeExternalStyleConfig(layerId, overrides); + setSelectedRenderLayer(targetLayer); + setStyleConfig(cloneStyleConfig(next)); + void activateStyle(layerId, next); + }, + [activateStyle, getRenderLayersById, normalizeExternalStyleConfig, open], + ); + + const handleApply = useCallback(() => { + const layerId = selectedRenderLayer?.get("value"); + if (!isDefaultLayerId(layerId)) return; + void activateStyle(layerId, styleConfig); + }, [activateStyle, selectedRenderLayer, styleConfig]); + + const handleReset = useCallback(() => { + const layerId = selectedRenderLayer?.get("value"); + if (isDefaultLayerId(layerId)) resetLayer(layerId); + }, [resetLayer, selectedRenderLayer]); + + const handleLayerChange = useCallback( + (layerId: string) => { + const currentLayerId = selectedRenderLayer?.get("value"); + if (isDefaultLayerId(currentLayerId)) { + draftsRef.current.set(currentLayerId, cloneStyleConfig(styleConfig)); + const applied = latestLayerStyleStatesRef.current.find( + (state) => state.layerId === currentLayerId && state.isActive, + ); + if (applied) { + const resolved = resolveStyleFromLegend( + applied.styleConfig, + applied.legendConfig, + ); + if (resolved) { + const { variables } = createRenderInputs(applied.styleConfig, resolved); + activeMaps.forEach((targetMap) => + getController(targetMap, currentLayerId)?.updateVariables(variables), + ); + } + } + } + const nextLayer = renderLayers.find((layer) => layer.get("value") === layerId); + setSelectedRenderLayer(nextLayer); + if (!nextLayer) return; + const draft = draftsRef.current.get(layerId); + const applied = latestLayerStyleStatesRef.current.find((state) => state.layerId === layerId); + const fallback = isDefaultLayerId(layerId) + ? createDefaultLayerStyleState(layerId).styleConfig + : createEmptyStyleConfig(); + setStyleConfig(cloneStyleConfig(draft || applied?.styleConfig || fallback)); + }, + [activeMaps, getController, renderLayers, selectedRenderLayer, styleConfig], ); const handlePropertyChange = useCallback( (property: string) => { - setStyleConfig((prev) => ({ - ...prev, + setStyleConfig((previous) => ({ + ...previous, property, customBreaks: - prev.classificationMethod === "custom_breaks" - ? getBreakDefaults(prev.segments, property) - : prev.customBreaks, + previous.classificationMethod === "custom_breaks" + ? getDefaultBreaks(previous.segments, property) + : previous.customBreaks, })); }, - [getBreakDefaults] + [getDefaultBreaks], ); const handleClassificationMethodChange = useCallback( - (classificationMethod: string) => { - setStyleConfig((prev) => ({ - ...prev, + (classificationMethod: ClassificationMethod) => { + setStyleConfig((previous) => ({ + ...previous, classificationMethod, customBreaks: classificationMethod === "custom_breaks" - ? getBreakDefaults(prev.segments, prev.property) - : prev.customBreaks, + ? getDefaultBreaks(previous.segments, previous.property) + : previous.customBreaks, })); }, - [getBreakDefaults] + [getDefaultBreaks], ); const handleSegmentsChange = useCallback( (segments: number) => { - setStyleConfig((prev) => { - const newCustomColors = [...(prev.customColors || [])]; - return { - ...prev, - segments, - customBreaks: - prev.classificationMethod === "custom_breaks" - ? getBreakDefaults(segments, prev.property) - : prev.customBreaks, - customColors: getDefaultCustomColors(segments, newCustomColors), - }; - }); + setStyleConfig((previous) => ({ + ...previous, + segments, + customBreaks: + previous.classificationMethod === "custom_breaks" + ? getDefaultBreaks(segments, previous.property) + : previous.customBreaks, + customColors: getDefaultCustomColors(segments, previous.customColors), + })); }, - [getBreakDefaults] + [getDefaultBreaks], ); - const handleCustomBreakChange = useCallback( - (index: number, value: string) => { - const nextValue = parseFloat(value); - setStyleConfig((prev) => { - const nextBreaks = [...(prev.customBreaks || [])]; - while (nextBreaks.length < prev.segments) { - nextBreaks.push(0); - } - nextBreaks[index] = isNaN(nextValue) ? 0 : Math.max(0, nextValue); - return { ...prev, customBreaks: nextBreaks }; - }); - }, - [] - ); + const handleCustomBreakChange = useCallback((index: number, value: string) => { + setStyleConfig((previous) => { + const boundaries = [...(previous.customBreaks || [])]; + while (boundaries.length < previous.segments + 1) boundaries.push(0); + boundaries[index] = value.trim() === "" ? Number.NaN : Number(value); + return { ...previous, customBreaks: boundaries }; + }); + }, []); - const handleCustomBreakBlur = useCallback(() => { - setStyleConfig((prev) => ({ - ...prev, - customBreaks: [...(prev.customBreaks || [])] - .slice(0, prev.segments + 1) - .sort((a, b) => a - b), + const handleColorTypeChange = useCallback((colorType: ColorType) => { + setStyleConfig((previous) => ({ + ...previous, + colorType, + customColors: + colorType === "custom" + ? getDefaultCustomColors(previous.segments, previous.customColors) + : previous.customColors, })); }, []); - const handleColorTypeChange = useCallback((colorType: string) => { - setStyleConfig((prev) => { - let customColors = prev.customColors; - if (colorType === "custom" && (!customColors || customColors.length === 0)) { - customColors = getDefaultCustomColors(prev.segments, []); - } + const selectedLayerId = selectedRenderLayer?.get("value"); + const appliedStyleConfig = isDefaultLayerId(selectedLayerId) + ? layerStyleStates.find((state) => state.layerId === selectedLayerId && state.isActive) + ?.styleConfig + : undefined; + const validation = useMemo(() => validateStyleConfig(styleConfig), [styleConfig]); + const isDirty = requiresStyleApply(appliedStyleConfig, styleConfig); - return { - ...prev, - colorType, - adjustWidthByProperty: colorType === "single" ? true : prev.adjustWidthByProperty, - customColors, - }; + useEffect(() => { + if (!appliedStyleConfig || !isDefaultLayerId(selectedLayerId)) return; + if (requiresStyleApply(appliedStyleConfig, styleConfig)) return; + if (!validation.valid) return; + const state = layerStyleStates.find((item) => item.layerId === selectedLayerId); + if (!state || state.legendConfig.breaks.length !== styleConfig.segments + 1) return; + if (configsEqual(styleConfig, appliedStyleConfig)) return; + const resolved = resolveStyleFromLegend(styleConfig, state.legendConfig); + if (!resolved) return; + const { variables, signature: renderSignature } = createRenderInputs( + styleConfig, + resolved, + ); + activeMaps.forEach((targetMap) => { + const key = getMapKey(targetMap, selectedLayerId); + getController(targetMap, selectedLayerId)?.updateVariables(variables); + const previousInput = renderInputsRef.current.get(key); + if (previousInput) { + renderInputsRef.current.set(key, { + ...previousInput, + signature: renderSignature, + }); + } }); - }, []); + const nextStyleConfig = cloneStyleConfig(styleConfig); + draftsRef.current.set(selectedLayerId, nextStyleConfig); + upsertLayerStyleState({ + ...state, + styleConfig: nextStyleConfig, + legendConfig: { + ...state.legendConfig, + colors: resolved.isConstant ? resolved.colors.slice(0, 1) : resolved.colors, + dimensions: resolved.isConstant + ? resolved.dimensions.slice(0, 1) + : resolved.dimensions, + }, + }); + syncAuxiliaryLayers(selectedLayerId, nextStyleConfig); + syncContoursForStyle(selectedLayerId, styleConfig, resolved); + }, [ + activeMaps, + appliedStyleConfig, + getController, + getMapKey, + layerStyleStates, + selectedLayerId, + styleConfig, + syncAuxiliaryLayers, + syncContoursForStyle, + upsertLayerStyleState, + validation.valid, + ]); + + useEffect(() => { + if (!persistenceReady) return; + latestLayerStyleStatesRef.current + .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) + .forEach((state) => void renderAppliedStyle(state.layerId as DefaultLayerStyleId, state.styleConfig)); + }, [ + activeMaps, + compareJunctionCalData, + comparePipeCalData, + currentJunctionCalData, + currentPipeCalData, + diameterRange, + elevationRange, + persistenceReady, + renderAppliedStyle, + ]); + + useEffect(() => { + const activeMapKeys = new Set( + activeMaps.map((targetMap) => getMapKey(targetMap, "").replace(/:$/, "")), + ); + controllersRef.current.forEach((controller, key) => { + const mapKey = key.slice(0, key.lastIndexOf(":")); + if (activeMapKeys.has(mapKey)) return; + controller.dispose(); + controllersRef.current.delete(key); + renderInputsRef.current.delete(key); + }); + }, [activeMaps, getMapKey]); useEffect(() => { if ( forceStyleAutoApplyVersion <= 0 || forceStyleAutoApplyVersion === lastForceStyleAutoApplyVersionRef.current - ) { - return; - } - + ) return; lastForceStyleAutoApplyVersionRef.current = forceStyleAutoApplyVersion; - - const defaultJunctionStyleState = { - ...createDefaultLayerStyleState("junctions"), - isActive: true, - }; - const defaultPipeStyleState = { - ...createDefaultLayerStyleState("pipes"), - isActive: true, - }; - - setLayerStyleStates((prev) => { - const nextStates = [...prev]; - [defaultJunctionStyleState, defaultPipeStyleState].forEach((defaultState) => { - const index = nextStates.findIndex((state) => state.layerId === defaultState.layerId); - if (index === -1) { - nextStates.push(defaultState); - } else { - nextStates[index] = defaultState; - } - }); - latestLayerStyleStatesRef.current = nextStates; - return nextStates; + (["junctions", "pipes"] as DefaultLayerStyleId[]).forEach((layerId) => { + const defaults = createDefaultLayerStyleState(layerId).styleConfig; + void activateStyle(layerId, defaults, false); + if (selectedRenderLayer?.get("value") === layerId) { + setStyleConfig(cloneStyleConfig(defaults)); + } }); + }, [activateStyle, forceStyleAutoApplyVersion, selectedRenderLayer]); - setJunctionText?.(defaultJunctionStyleState.styleConfig.property); - setPipeText?.(defaultPipeStyleState.styleConfig.property); - setShowJunctionTextLayer?.(defaultJunctionStyleState.styleConfig.showLabels); - setShowPipeTextLayer?.(defaultPipeStyleState.styleConfig.showLabels); - setShowJunctionId?.(defaultJunctionStyleState.styleConfig.showId); - setShowPipeId?.(defaultPipeStyleState.styleConfig.showId); - setContourLayerAvailable?.( - defaultJunctionStyleState.styleConfig.property === "pressure" + const storageKey = `${workspace}:map-layer-style:v${STYLE_STORAGE_VERSION}`; + useEffect(() => { + // Restoring an external browser preference intentionally initializes React state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setPersistenceReady(false); + let restored = createDefaultLayerStyleStates(); + try { + const raw = window.localStorage.getItem(storageKey); + if (raw) { + const document = JSON.parse(raw) as { + version?: number; + layers?: Partial<Record<DefaultLayerStyleId, StyleConfig>>; + }; + if (document.version === STYLE_STORAGE_VERSION && document.layers) { + restored = createDefaultLayerStyleStates().map((state) => { + if (!isDefaultLayerId(state.layerId)) return state; + const stored = document.layers?.[state.layerId]; + if (!stored || !validateStyleConfig(stored).valid) return state; + return { + ...state, + styleConfig: cloneStyleConfig(stored), + legendConfig: { ...state.legendConfig, property: stored.property }, + isActive: true, + }; + }); + } + } + } catch (error) { + console.warn("Restore layer styles failed", error); + } + latestLayerStyleStatesRef.current = restored; + setLayerStyleStates(restored); + const restoredSelection = restored.find((state) => state.layerId === "junctions"); + if (restoredSelection) setStyleConfig(cloneStyleConfig(restoredSelection.styleConfig)); + restored + .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) + .forEach((state) => syncAuxiliaryLayers(state.layerId as DefaultLayerStyleId, state.styleConfig)); + setPersistenceReady(true); + }, [setLayerStyleStates, storageKey, syncAuxiliaryLayers]); + + useEffect(() => { + if (!persistenceReady) return; + const layers = Object.fromEntries( + layerStyleStates + .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) + .map((state) => [state.layerId, state.styleConfig]), ); - const isDefaultPipeFlow = - defaultPipeStyleState.styleConfig.property === "flow"; - setWaterflowLayerAvailable?.(isDefaultPipeFlow); - if (!isDefaultPipeFlow) { - setShowWaterflowLayer?.(false); - } - setApplyJunctionStyle(true); - setApplyPipeStyle(true); - - const selectedLayerId = selectedRenderLayer?.get("value"); - if (selectedLayerId === "junctions") { - setStyleConfig(defaultJunctionStyleState.styleConfig); - } else if (selectedLayerId === "pipes") { - setStyleConfig(defaultPipeStyleState.styleConfig); - } - }, [ - forceStyleAutoApplyVersion, - selectedRenderLayer, - setContourLayerAvailable, - setJunctionText, - setLayerStyleStates, - setPipeText, - setShowJunctionId, - setShowJunctionTextLayer, - setShowPipeId, - setShowPipeTextLayer, - setShowWaterflowLayer, - setWaterflowLayerAvailable, - ]); - - useEffect(() => { - const isUserTrigger = styleUpdateTrigger !== prevStyleUpdateTriggerRef.current; - prevStyleUpdateTriggerRef.current = styleUpdateTrigger; - - const updateJunctionStyle = () => { - const junctionStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === "junctions" + try { + window.localStorage.setItem( + storageKey, + JSON.stringify({ version: STYLE_STORAGE_VERSION, layers }), ); - const isElevation = - junctionStyleState?.styleConfig.property === "elevation"; - - applyClassificationStyle("junctions", junctionStyleState?.styleConfig); - - if (isElevation) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - }); - return; - } - - activeMaps.forEach((targetMap) => { - const targetData = getDataForMap(targetMap, "junctions"); - if (!targetData || targetData.length === 0) { - return; - } - updateVectorTileSource(targetMap, "junctions", junctionText, targetData); - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - attachVectorTileSourceLoadedEvent( - targetMap, - "junctions", - junctionText, - targetData - ); - }); - }; - - const updatePipeStyle = () => { - const pipeStyleState = latestLayerStyleStatesRef.current.find( - (state) => state.layerId === "pipes" - ); - const isDiameter = pipeStyleState?.styleConfig.property === "diameter"; - - applyClassificationStyle("pipes", pipeStyleState?.styleConfig); - - if (isDiameter) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - return; - } - - activeMaps.forEach((targetMap) => { - const targetData = getDataForMap(targetMap, "pipes"); - if (!targetData || targetData.length === 0) { - return; - } - updateVectorTileSource(targetMap, "pipes", pipeText, targetData); - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - attachVectorTileSourceLoadedEvent(targetMap, "pipes", pipeText, targetData); - }); - }; - - if (isUserTrigger) { - if (selectedRenderLayer?.get("value") === "junctions") { - updateJunctionStyle(); - } else if (selectedRenderLayer?.get("value") === "pipes") { - updatePipeStyle(); - } - return; + } catch (error) { + console.warn("Save layer styles failed", error); } + }, [layerStyleStates, persistenceReady, storageKey]); - const isElevation = junctionText === "elevation"; - const isDiameter = pipeText === "diameter"; - - if ( - applyJunctionStyle && - ((currentJunctionCalData && currentJunctionCalData.length > 0) || isElevation) - ) { - updateJunctionStyle(); - } - - if ( - applyPipeStyle && - ((currentPipeCalData && currentPipeCalData.length > 0) || isDiameter) - ) { - updatePipeStyle(); - } - - if (!applyJunctionStyle) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - }); - } - - if (!applyPipeStyle) { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - } - // This effect is intentionally driven by explicit style triggers and data snapshots. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - styleUpdateTrigger, - applyJunctionStyle, - applyPipeStyle, - currentJunctionCalData, - currentPipeCalData, - compareJunctionCalData, - comparePipeCalData, - activeMaps, - ]); - - useEffect(() => { - return () => { - activeMaps.forEach((targetMap) => { - removeVectorTileSourceLoadedEvent(targetMap, "junctions"); - removeVectorTileSourceLoadedEvent(targetMap, "pipes"); - }); - }; - }, [activeMaps, removeVectorTileSourceLoadedEvent]); - - useEffect(() => { - if (!map) { - return; - } - - const updateVisibleLayers = () => { - const layers = map.getAllLayers(); - const webGLVectorTileLayers = layers.filter( - (layer) => - layer.get("value") === "junctions" || layer.get("value") === "pipes" - ) as WebGLVectorTileLayer[]; - - setRenderLayers(webGLVectorTileLayers); - }; - - updateVisibleLayers(); - }, [map]); + useEffect( + () => () => { + controllersRef.current.forEach((controller) => controller.dispose()); + controllersRef.current.clear(); + renderInputsRef.current.clear(); + }, + [], + ); return { isReady: Boolean(data), @@ -1198,16 +949,18 @@ export const useStyleEditor = ({ styleConfig, setStyleConfig, availableProperties, + validationErrors: validation.errors, + isDirty, + isApplying, handleLayerChange, handlePropertyChange, handleClassificationMethodChange, handleSegmentsChange, handleCustomBreakChange, - handleCustomBreakBlur, handleColorTypeChange, handleApply, handleReset, applyExternalStyle, - resetExternalStyle, + resetExternalStyle: resetLayer, }; }; diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 51a6786..0440c54 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useRef, } from "react"; -import { Map as OlMap, VectorTile } from "ol"; +import { Map as OlMap } from "ol"; import View from "ol/View.js"; import "ol/ol.css"; import MapTools from "./MapTools"; @@ -31,13 +31,47 @@ import { disposeMapResources, markMapResourcePersistent, } from "./mapLifecycle"; -import { createOperationalMapResources } from "./operationalLayers"; +import { + createOperationalMapResources, + createOperationalMapSources, +} from "./operationalLayers"; import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime"; import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig"; +import { + TileFeatureIndex, + clipLineStringPartsToExtent, + coordinatesToLonLat, + lineStringFromFlatCoordinates, + type TileFeatureInstance, +} from "./tileFeatureIndex"; interface MapComponentProps { children?: React.ReactNode; } + +const COMPARE_OPEN_START_MARK = "tjwater:compare-open-start"; +const COMPARE_OPEN_READY_MARK = "tjwater:compare-open-ready"; +const COMPARE_OPEN_MEASURE = "tjwater:compare-open"; + +const markCompareOpenStart = () => { + performance.clearMarks(COMPARE_OPEN_START_MARK); + performance.clearMarks(COMPARE_OPEN_READY_MARK); + performance.clearMeasures(COMPARE_OPEN_MEASURE); + performance.mark(COMPARE_OPEN_START_MARK); +}; + +const markCompareOpenReady = () => { + if (performance.getEntriesByName(COMPARE_OPEN_START_MARK).length === 0) { + return; + } + performance.mark(COMPARE_OPEN_READY_MARK); + performance.measure( + COMPARE_OPEN_MEASURE, + COMPARE_OPEN_START_MARK, + COMPARE_OPEN_READY_MARK, + ); +}; + interface DataContextType { currentTime?: number; // 当前时间 setCurrentTime?: React.Dispatch<React.SetStateAction<number>>; @@ -122,6 +156,57 @@ function debounce<F extends (...args: any[]) => any>( return debounced; } +const indexCalculationRecords = (records: any[]) => + new Map(records.map((record) => [String(record.ID), record])); + +const mergeJunctionValues = ( + features: any[], + records: any[], + property: string, +) => { + const recordsById = indexCalculationRecords(records); + return features.map((feature) => { + const record = recordsById.get(String(feature.id)); + if (!record) return feature; + const value = isLpsFlowProperty(property) + ? toM3h(record.value, "lps") + : record.value; + return { ...feature, [property]: value }; + }); +}; + +const mergePipeValues = ( + features: any[], + records: any[], + property: string, +) => { + const recordsById = indexCalculationRecords(records); + const isFlow = property === "flow"; + return features.map((feature) => { + const record = recordsById.get(String(feature.id)); + if (!record) return feature; + const value = isFlow ? toM3h(record.value, "lps") : record.value; + const reverseFlow = isFlow && record.value < 0; + return { + ...feature, + [property]: isFlow ? Math.abs(value) : value, + flowFlag: reverseFlow ? -1 : 1, + path: reverseFlow ? [...feature.path].reverse() : feature.path, + }; + }); +}; + +const getNumericRange = (values: number[]): [number, number] | undefined => { + let min = Infinity; + let max = -Infinity; + values.forEach((value) => { + if (!Number.isFinite(value)) return; + min = Math.min(min, value); + max = Math.max(max, value); + }); + return min === Infinity ? undefined : [min, max]; +}; + export const useMap = () => { return useContext(MapContext); }; @@ -151,7 +236,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const compareDeckLayerRef = useRef<DeckLayer | null>(null); const isDisposingRef = useRef(false); const isCompareDisposingRef = useRef(false); - const pendingTimeoutsRef = useRef<number[]>([]); const [map, setMap] = useState<OlMap>(); const [deckLayer, setDeckLayer] = useState<DeckLayer>(); @@ -174,14 +258,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { ); const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]); const [isCompareMode, setCompareMode] = useState(false); - // junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 - // currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 + // junctionData 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。 + // pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。 const [junctionData, setJunctionDataState] = useState<any[]>([]); const [pipeData, setPipeDataState] = useState<any[]>([]); - const junctionDataIds = useRef(new Set<string>()); - const pipeDataIds = useRef(new Set<string>()); - const tileJunctionDataBuffer = useRef<any[]>([]); - const tilePipeDataBuffer = useRef<any[]>([]); + const [pipeFragments, setPipeFragments] = useState<any[]>([]); + const junctionIndexRef = useRef<TileFeatureIndex | null>(null); + const pipeIndexRef = useRef<TileFeatureIndex | null>(null); const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示 const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示 @@ -191,7 +274,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const [junctionText, setJunctionText] = useState("pressure"); const [pipeText, setPipeText] = useState("velocity"); const [contours, setContours] = useState<any[]>([]); - const flowAnimation = useRef(false); // 添加动画控制标志 const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示 const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] = useState(false); // 控制等高线图层显示 @@ -199,67 +281,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别 // 实时合并计算结果到基础地理数据中 - const mergedJunctionData = useMemo(() => { - const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r])); - return junctionData.map((j) => { - const record = nodeMap.get(j.id); - let val = record ? record.value : undefined; - if (val !== undefined && isLpsFlowProperty(junctionText)) { - val = toM3h(val, "lps"); - } - return record ? { ...j, [junctionText]: val } : j; - }); - }, [junctionData, currentJunctionCalData, junctionText]); - - const mergedPipeData = useMemo(() => { - const linkMap = new Map(currentPipeCalData.map((r: any) => [r.ID, r])); - return pipeData.map((p) => { - const record = linkMap.get(p.id); - if (!record) return p; - const isFlow = pipeText === "flow"; - let val = record.value; - if (val !== undefined && isFlow) { - val = toM3h(val, "lps"); - } - return { - ...p, - [pipeText]: isFlow ? Math.abs(val) : val, - flowFlag: isFlow && record.value < 0 ? -1 : 1, - path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, - }; - }); - }, [pipeData, currentPipeCalData, pipeText]); - - const mergedCompareJunctionData = useMemo(() => { - const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r])); - return junctionData.map((j) => { - const record = nodeMap.get(j.id); - let val = record ? record.value : undefined; - if (val !== undefined && isLpsFlowProperty(junctionText)) { - val = toM3h(val, "lps"); - } - return record ? { ...j, [junctionText]: val } : j; - }); - }, [junctionData, compareJunctionCalData, junctionText]); - - const mergedComparePipeData = useMemo(() => { - const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r])); - return pipeData.map((p) => { - const record = linkMap.get(p.id); - if (!record) return p; - const isFlow = pipeText === "flow"; - let val = record.value; - if (val !== undefined && isFlow) { - val = toM3h(val, "lps"); - } - return { - ...p, - [pipeText]: isFlow ? Math.abs(val) : val, - flowFlag: isFlow && record.value < 0 ? -1 : 1, - path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, - }; - }); - }, [pipeData, comparePipeCalData, pipeText]); + const mergedJunctionData = useMemo( + () => + mergeJunctionValues(junctionData, currentJunctionCalData, junctionText), + [junctionData, currentJunctionCalData, junctionText], + ); + const mergedPipeData = useMemo( + () => mergePipeValues(pipeData, currentPipeCalData, pipeText), + [pipeData, currentPipeCalData, pipeText], + ); + const mergedPipeFragments = useMemo( + () => mergePipeValues(pipeFragments, currentPipeCalData, pipeText), + [pipeFragments, currentPipeCalData, pipeText], + ); + const mergedCompareJunctionData = useMemo( + () => + isCompareMode + ? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText) + : [], + [isCompareMode, junctionData, compareJunctionCalData, junctionText], + ); + const mergedComparePipeData = useMemo( + () => + isCompareMode + ? mergePipeValues(pipeData, comparePipeCalData, pipeText) + : [], + [isCompareMode, pipeData, comparePipeCalData, pipeText], + ); + const mergedComparePipeFragments = useMemo( + () => + isCompareMode + ? mergePipeValues(pipeFragments, comparePipeCalData, pipeText) + : [], + [isCompareMode, pipeFragments, comparePipeCalData, pipeText], + ); const [diameterRange, setDiameterRange] = useState< [number, number] | undefined @@ -271,7 +326,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { useState(0); const toggleCompareMode = useCallback(() => { - setCompareMode((prev) => !prev); + setCompareMode((prev) => { + if (!prev) markCompareOpenStart(); + return !prev; + }); }, []); const maps = useMemo( @@ -288,91 +346,126 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { [compareDeckLayer, deckLayer, isCompareMode], ); - const setJunctionData = (newData: any[]) => { - const uniqueNewData = newData.filter((item) => { - if (!item || !item.id) return false; - if (!junctionDataIds.current.has(item.id)) { - junctionDataIds.current.add(item.id); - return true; + const buildPipeFragments = useCallback((instance: TileFeatureInstance) => { + const tileCoordinates = lineStringFromFlatCoordinates( + instance.flatCoordinates, + instance.stride, + ); + const clippedParts = clipLineStringPartsToExtent( + tileCoordinates, + instance.tileExtent, + ); + return clippedParts.flatMap((clippedCoordinates, partIndex) => { + const path = coordinatesToLonLat(clippedCoordinates); + const lineStringFeature = lineString(path); + const fragmentLength = length(lineStringFeature); + if (fragmentLength <= 0) return []; + + const timestamps = [0]; + let cumulativeLength = 0; + for (let i = 1; i < path.length; i += 1) { + cumulativeLength += length(lineString([path[i - 1], path[i]])); + timestamps.push((cumulativeLength / fragmentLength) * 10); } - return false; + + const midPoint = along(lineStringFeature, fragmentLength / 2).geometry + .coordinates; + const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry + .coordinates; + const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry + .coordinates; + let lineAngle = bearing(prevPoint, nextPoint); + lineAngle = -lineAngle + 90; + if (lineAngle < -90 || lineAngle > 90) { + lineAngle += 180; + } + + return [ + { + instanceKey: `${instance.instanceKey}/${partIndex}`, + id: instance.featureId, + diameter: instance.properties.diameter || 0, + length: instance.properties.length || fragmentLength * 1000, + path, + position: midPoint, + angle: lineAngle, + timestamps, + fragmentLength, + }, + ]; }); - if (uniqueNewData.length > 0) { - setJunctionDataState((prev) => prev.concat(uniqueNewData)); - setElevationRange((prev) => { - const elevations = uniqueNewData - .map((d) => d.elevation) - .filter((v) => typeof v === "number"); - if (elevations.length === 0) return prev; - - let newMin = elevations[0]; - let newMax = elevations[0]; - for (let i = 1; i < elevations.length; i++) { - if (elevations[i] < newMin) newMin = elevations[i]; - if (elevations[i] > newMax) newMax = elevations[i]; - } - - if (!prev) { - return [newMin, newMax]; - } - return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)]; - }); - } - }; - - const setPipeData = (newData: any[]) => { - const uniqueNewData = newData.filter((item) => { - if (!item || !item.id) return false; - if (!pipeDataIds.current.has(item.id)) { - pipeDataIds.current.add(item.id); - return true; - } - return false; - }); - if (uniqueNewData.length > 0) { - setPipeDataState((prev) => prev.concat(uniqueNewData)); - setDiameterRange((prev) => { - const diameters = uniqueNewData - .map((d) => d.diameter) - .filter((v) => typeof v === "number"); - if (diameters.length === 0) return prev; - - let newMin = diameters[0]; - let newMax = diameters[0]; - for (let i = 1; i < diameters.length; i++) { - if (diameters[i] < newMin) newMin = diameters[i]; - if (diameters[i] > newMax) newMax = diameters[i]; - } - - if (!prev) { - return [newMin, newMax]; - } - return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)]; - }); - } - }; - - const debouncedUpdateDataRef = useRef<DebouncedFunction<() => void> | null>( - null, - ); - - useEffect(() => { - debouncedUpdateDataRef.current = debounce(() => { - if (tileJunctionDataBuffer.current.length > 0) { - setJunctionData(tileJunctionDataBuffer.current); - tileJunctionDataBuffer.current = []; - } - if (tilePipeDataBuffer.current.length > 0) { - setPipeData(tilePipeDataBuffer.current); - tilePipeDataBuffer.current = []; - } - }, 100); - - return () => { - debouncedUpdateDataRef.current?.cancel(); - debouncedUpdateDataRef.current = null; - }; }, []); + + const publishActiveTileSnapshot = useCallback( + (targetMap: OlMap) => { + const zoom = targetMap.getView().getZoom() ?? 0; + const junctionSnapshot = junctionIndexRef.current?.getSnapshot( + targetMap, + zoom, + ); + const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom); + + const nextJunctionData = Array.from( + junctionSnapshot?.instancesById.values() ?? [], + ) + .map((instances) => instances[0]) + .filter(Boolean) + .map((instance) => { + const [x, y] = lineStringFromFlatCoordinates( + instance.flatCoordinates, + instance.stride, + )[0]; + return { + id: instance.featureId, + instanceKey: instance.instanceKey, + position: toLonLat([x, y]), + elevation: instance.properties.elevation || 0, + demand: instance.properties.demand || 0, + }; + }) + .sort((a, b) => String(a.id).localeCompare(String(b.id))); + + const nextPipeFragments = (pipeSnapshot?.instances ?? []) + .filter((instance) => instance.geometryType.includes("Line")) + .flatMap(buildPipeFragments) + .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)); + + const labelById = new Map<string, any>(); + nextPipeFragments.forEach((fragment) => { + const previous = labelById.get(fragment.id); + if ( + !previous || + fragment.fragmentLength > previous.fragmentLength || + (fragment.fragmentLength === previous.fragmentLength && + fragment.instanceKey.localeCompare(previous.instanceKey) < 0) + ) { + labelById.set(fragment.id, fragment); + } + }); + const nextPipeLabels = Array.from(labelById.values()).sort((a, b) => + String(a.id).localeCompare(String(b.id)), + ); + + setJunctionDataState(nextJunctionData); + setPipeFragments(nextPipeFragments); + setPipeDataState(nextPipeLabels); + setElevationRange( + getNumericRange(nextJunctionData.map((item) => item.elevation)), + ); + setDiameterRange( + getNumericRange(nextPipeLabels.map((item) => item.diameter)), + ); + }, + [buildPipeFragments], + ); + const operationalSources = useMemo( + () => + createOperationalMapSources({ + mapUrl: MAP_URL, + workspace: MAP_WORKSPACE, + }), + [MAP_URL, MAP_WORKSPACE], + ); const operationalResources = useMemo( () => createOperationalMapResources({ @@ -380,8 +473,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { workspace: MAP_WORKSPACE, extent: MAP_EXTENT, persistent: true, + sources: operationalSources, }), - [MAP_URL, MAP_WORKSPACE, MAP_EXTENT], + [MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources], ); const { junctions: junctionSource, pipes: pipeSource } = operationalResources.sources; @@ -395,60 +489,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { return; } isDisposingRef.current = false; - const activeJunctionDataIds = junctionDataIds.current; - const activePipeDataIds = pipeDataIds.current; - const addTimeout = (callback: () => void, delay: number) => { - const timerId = window.setTimeout(() => { - pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter( - (id) => id !== timerId, - ); - if (isDisposingRef.current) return; - callback(); - }, delay); - pendingTimeoutsRef.current.push(timerId); - return timerId; - }; + junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource); + pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource); - const clearPendingTimeouts = () => { - pendingTimeoutsRef.current.forEach((id) => clearTimeout(id)); - pendingTimeoutsRef.current = []; - }; - - // 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示 const handleJunctionTileLoadEnd = (event: any) => { if (isDisposingRef.current) return; try { - if (event.tile instanceof VectorTile) { - const renderFeatures = event.tile.getFeatures(); - const data = new Map(); - - renderFeatures.forEach((renderFeature: any) => { - const props = renderFeature.getProperties(); - const featureId = props.id; - if (featureId && !junctionDataIds.current.has(featureId)) { - const geometry = renderFeature.getGeometry(); - if (geometry) { - const coordinates = geometry.getFlatCoordinates(); - const coordWGS84 = toLonLat(coordinates); - data.set(featureId, { - id: featureId, - position: coordWGS84, - elevation: props.elevation || 0, - demand: props.demand || 0, - }); - } - } - }); - - const uniqueData = Array.from(data.values()); - if (uniqueData.length > 0) { - uniqueData.forEach((item) => - tileJunctionDataBuffer.current.push(item), - ); - debouncedUpdateDataRef.current?.(); - } - } + junctionIndexRef.current?.registerTile(event.tile); + scheduleActiveTileSnapshot(); } catch (error) { console.error("Junction tile load error:", error); } @@ -456,86 +505,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const handlePipeTileLoadEnd = (event: any) => { if (isDisposingRef.current) return; try { - if (event.tile instanceof VectorTile) { - const renderFeatures = event.tile.getFeatures(); - const data = new Map(); - - renderFeatures.forEach((renderFeature: any) => { - try { - const props = renderFeature.getProperties(); - const featureId = props.id; - if (featureId && !pipeDataIds.current.has(featureId)) { - const geometry = renderFeature.getGeometry(); - if (geometry) { - const flatCoordinates = geometry.getFlatCoordinates(); - const stride = geometry.getStride(); // 获取步长,通常为 2 - // 重建为 LineString GeoJSON 格式的 coordinates: [[x1, y1], [x2, y2], ...] - const lineCoords = []; - for (let i = 0; i < flatCoordinates.length; i += stride) { - lineCoords.push([ - flatCoordinates[i], - flatCoordinates[i + 1], - ]); - } - const lineCoordsWGS84 = lineCoords.map((coord) => { - const [lon, lat] = toLonLat(coord); - return [lon, lat]; - }); - // 添加验证:确保至少有 2 个坐标点 - if (lineCoordsWGS84.length < 2) return; // 跳过此特征 - // 计算中点 - const lineStringFeature = lineString(lineCoordsWGS84); - const lineLength = length(lineStringFeature); - const midPoint = along(lineStringFeature, lineLength / 2) - .geometry.coordinates; - // 计算角度 - const prevPoint = along(lineStringFeature, lineLength * 0.49) - .geometry.coordinates; - const nextPoint = along(lineStringFeature, lineLength * 0.51) - .geometry.coordinates; - let lineAngle = bearing(prevPoint, nextPoint); - lineAngle = -lineAngle + 90; - if (lineAngle < -90 || lineAngle > 90) { - lineAngle += 180; - } - - // 计算时间戳(可选) - const numSegments = lineCoordsWGS84.length - 1; - const timestamps = [0]; - if (numSegments > 0) { - for (let i = 1; i <= numSegments; i++) { - timestamps.push((i / numSegments) * 10); - } - } - - data.set(featureId, { - id: featureId, - diameter: props.diameter || 0, - length: props.length || 0, - path: lineCoordsWGS84, // 使用重建后的坐标 - position: midPoint, - angle: lineAngle, - timestamps, - }); - } - } - } catch (geomError) { - console.error("Geometry calculation error:", geomError); - } - }); - - const uniqueData = Array.from(data.values()); - if (uniqueData.length > 0) { - uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item)); - debouncedUpdateDataRef.current?.(); - } - } + pipeIndexRef.current?.registerTile(event.tile); + scheduleActiveTileSnapshot(); } catch (error) { console.error("Pipe tile load error:", error); } }; - junctionSource.on("tileloadend", handleJunctionTileLoadEnd); - pipeSource.on("tileloadend", handlePipeTileLoadEnd); // 监听 junctionsLayer 的 visible 变化 const handleJunctionVisibilityChange = () => { const isVisible = junctionsLayer.getVisible(); @@ -559,6 +534,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { layers: operationalResources.orderedLayers.slice(), controls: [], }); + const scheduleActiveTileSnapshot = debounce( + () => publishActiveTileSnapshot(map), + 50, + ); + junctionSource.on("tileloadend", handleJunctionTileLoadEnd); + pipeSource.on("tileloadend", handlePipeTileLoadEnd); map.getInteractions().forEach(markMapResourcePersistent); setMap(map); @@ -596,14 +577,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { duration: 1000, }); } - // 持久化视图(中心点 + 缩放),防抖写入 localStorage - const persistView = debounce(() => { + // 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。 + const handleViewChange = debounce(() => { if (isDisposingRef.current) return; + const view = map.getView(); + const zoom = view.getZoom() || 0; + setCurrentZoom(zoom); + junctionIndexRef.current?.scanLoadedTiles(); + pipeIndexRef.current?.scanLoadedTiles(); + scheduleActiveTileSnapshot(); try { - const view = map.getView(); const center = view.getCenter(); - const zoom = view.getZoom(); - if (center && typeof zoom === "number") { + if (center) { localStorage.setItem( MAP_VIEW_STORAGE_KEY, JSON.stringify({ center, zoom }), @@ -613,21 +598,16 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { console.warn("Save map view failed", err); } }, 250); - - // 监听缩放变化并持久化,同时更新 currentZoom - const handleViewChange = () => { - addTimeout(() => { - const zoom = map.getView().getZoom() || 0; - setCurrentZoom(zoom); - persistView(); - }, 250); - }; map.getView().on("change", handleViewChange); // 初始化当前缩放级别并强制触发瓦片加载 - addTimeout(() => { + const initializeTimer = window.setTimeout(() => { + if (isDisposingRef.current) return; const initialZoom = map.getView().getZoom() || 11; setCurrentZoom(initialZoom); + junctionIndexRef.current?.scanLoadedTiles(); + pipeIndexRef.current?.scanLoadedTiles(); + scheduleActiveTileSnapshot(); // 强制触发地图渲染,让瓦片加载事件触发 map.render(); }, 100); @@ -656,9 +636,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 清理函数 return () => { isDisposingRef.current = true; - clearPendingTimeouts(); - debouncedUpdateDataRef.current?.cancel(); - persistView.cancel(); + window.clearTimeout(initializeTimer); + scheduleActiveTileSnapshot.cancel(); + handleViewChange.cancel(); junctionSource.un("tileloadend", handleJunctionTileLoadEnd); pipeSource.un("tileloadend", handlePipeTileLoadEnd); map.getView().un("change", handleViewChange); @@ -677,10 +657,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // React Strict Mode re-runs effects with the same memoized layer instances. // Detach and clear them here, but leave final layer/source disposal to GC. disposeMapResources(map, { disposeLayers: false }); - activeJunctionDataIds.clear(); - activePipeDataIds.clear(); - tileJunctionDataBuffer.current = []; - tilePipeDataBuffer.current = []; + junctionIndexRef.current = null; + pipeIndexRef.current = null; + setJunctionDataState([]); + setPipeDataState([]); + setPipeFragments([]); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [MAP_WORKSPACE, MAP_EXTENT]); @@ -699,6 +680,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { mapUrl: MAP_URL, workspace: MAP_WORKSPACE, extent: MAP_EXTENT, + sources: operationalSources, }); const nextCompareMap = new OlMap({ target: compareMapRef.current, @@ -706,6 +688,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { layers: compareResources.orderedLayers.slice(), controls: [], }); + const handleCompareRenderComplete = () => markCompareOpenReady(); + nextCompareMap.once("rendercomplete", handleCompareRenderComplete); nextCompareMap.getAllLayers().forEach((layer) => { const layerId = layer.get("value"); if (!layerId) return; @@ -748,6 +732,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { return () => { isCompareDisposingRef.current = true; window.clearTimeout(resizeTimerId); + nextCompareMap.un("rendercomplete", handleCompareRenderComplete); if ( compareDeckLayerRef.current && !compareDeckLayerRef.current.isDisposedLayer() @@ -762,7 +747,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { compareDeckLayerRef.current = null; setCompareDeckLayer(undefined); setCompareMap(undefined); - disposeMapResources(nextCompareMap); + disposeMapResources(nextCompareMap, { disposeSources: false }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isCompareMode, map]); @@ -1015,11 +1000,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { // 控制流动动画开关 useEffect(() => { - flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0; + const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0; const shouldShowWaterflow = isWaterflowLayerAvailable && showWaterflowLayer && - flowAnimation.current && + hasFlowData && currentZoom >= 12 && currentZoom <= 24; @@ -1027,13 +1012,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const syncWaterflowLayer = ( targetDeckLayer: DeckLayer | null, - targetPipeData: any[], + targetPipeFragments: any[], disposing: boolean, ) => { if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { return; } - if (!shouldShowWaterflow || targetPipeData.length === 0) { + if (!shouldShowWaterflow || targetPipeFragments.length === 0) { targetDeckLayer.removeDeckLayer("waterflowLayer"); return; } @@ -1045,7 +1030,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const waterflowLayer = new TripsLayer({ id: "waterflowLayer", name: "水流", - data: targetPipeData, + data: targetPipeFragments, + getObjectId: (d: any) => d.instanceKey, getPath: (d) => d.path, getTimestamps: (d) => d.timestamps, getColor: [0, 220, 255], @@ -1067,13 +1053,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const animate = () => { syncWaterflowLayer( deckLayerRef.current, - mergedPipeData, + mergedPipeFragments, isDisposingRef.current, ); if (isCompareMode) { syncWaterflowLayer( compareDeckLayerRef.current, - mergedComparePipeData, + mergedComparePipeFragments, isCompareDisposingRef.current, ); } @@ -1092,8 +1078,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }, [ currentPipeCalData, currentZoom, - mergedPipeData, - mergedComparePipeData, + mergedPipeFragments, + mergedComparePipeFragments, isCompareMode, pipeText, isWaterflowLayerAvailable, diff --git a/src/components/olmap/core/layerStyleController.test.ts b/src/components/olmap/core/layerStyleController.test.ts new file mode 100644 index 0000000..116f614 --- /dev/null +++ b/src/components/olmap/core/layerStyleController.test.ts @@ -0,0 +1,87 @@ +jest.mock("ol/layer/WebGLVectorTile", () => ({ + __esModule: true, + default: class WebGLVectorTileLayer {}, +})); + +import { LayerStyleController } from "./layerStyleController"; + +describe("LayerStyleController", () => { + it("rebuilds only when the structural signature changes", () => { + const layer = { + getSource: () => null, + updateStyleVariables: jest.fn(), + setStyle: jest.fn(), + } as any; + const controller = new LayerStyleController({ + layer, + defaultStyle: { "stroke-color": "gray" }, + }); + const base = { + property: "pressure", + classCount: 5, + template: { "stroke-color": ["var", "tj_color_0"] } as any, + variables: { tj_color_0: "red" }, + }; + + controller.applyNative(base); + controller.applyNative({ ...base, variables: { tj_color_0: "blue" } }); + expect(layer.setStyle).toHaveBeenCalledTimes(1); + expect(layer.updateStyleVariables).toHaveBeenCalledTimes(2); + + controller.applyNative({ ...base, classCount: 6 }); + expect(layer.setStyle).toHaveBeenCalledTimes(2); + }); + + it("isolates primary and comparison values on a shared vector source", () => { + const properties: Record<string, unknown> = { id: "J-1" }; + const feature = { + properties, + properties_: properties, + get: (key: string) => properties[key], + }; + const listeners = new Set<(event: any) => void>(); + const source = { + sourceTiles_: { loaded: { getFeatures: () => [feature] } }, + on: (_type: string, listener: (event: any) => void) => listeners.add(listener), + un: (_type: string, listener: (event: any) => void) => listeners.delete(listener), + }; + const createLayer = () => ({ + getSource: () => source, + on: jest.fn(), + un: jest.fn(), + getOpacity: () => 1, + setOpacity: jest.fn(), + setStyle: jest.fn(), + updateStyleVariables: jest.fn(), + }); + const primary = new LayerStyleController({ + layer: createLayer() as any, + defaultStyle: { "circle-fill-color": "gray" }, + stateNamespace: "primary", + }); + const compare = new LayerStyleController({ + layer: createLayer() as any, + defaultStyle: { "circle-fill-color": "gray" }, + stateNamespace: "compare", + }); + const options = { + property: "pressure", + classCount: 5, + template: { "circle-fill-color": ["get", "pressure"] } as any, + variables: {}, + }; + + primary.applyRuntime(options, new Map([["J-1", 0.25]])); + compare.applyRuntime(options, new Map([["J-1", 0.75]])); + + expect(properties.primary_pressure).toBe(0.25); + expect(properties.compare_pressure).toBe(0.75); + expect(listeners.size).toBe(2); + + primary.dispose(); + expect(listeners.size).toBe(1); + expect(properties.compare_pressure).toBe(0.75); + compare.dispose(); + expect(listeners.size).toBe(0); + }); +}); diff --git a/src/components/olmap/core/layerStyleController.ts b/src/components/olmap/core/layerStyleController.ts new file mode 100644 index 0000000..ed714f2 --- /dev/null +++ b/src/components/olmap/core/layerStyleController.ts @@ -0,0 +1,126 @@ +import type OlMap from "ol/Map"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike, StyleVariables } from "ol/style/flat"; + +import { + buildVersionedFlatStyle, + sanitizeStyleIdentifier, + VectorTileStyleSession, +} from "./vectorTileStyleSession"; + +type FeatureStateValue = string | number | boolean | null; + +type ApplyStyleOptions = { + property: string; + classCount: number; + template: FlatStyleLike; + variables: StyleVariables; +}; + +type LayerStyleControllerOptions = { + layer: WebGLVectorTileLayer; + defaultStyle: FlatStyleLike; + map?: OlMap; + stateNamespace?: "primary" | "compare"; +}; + +export class LayerStyleController { + private readonly layer: WebGLVectorTileLayer; + private readonly source: VectorTileSource | null; + private readonly defaultStyle: FlatStyleLike; + private readonly map?: OlMap; + private readonly stateNamespace?: "primary" | "compare"; + private session: VectorTileStyleSession | null = null; + private templateSignature = ""; + + constructor(options: LayerStyleControllerOptions) { + this.layer = options.layer; + this.source = options.layer.getSource() as VectorTileSource | null; + this.defaultStyle = options.defaultStyle; + this.map = options.map; + this.stateNamespace = options.stateNamespace; + } + + applyNative(options: ApplyStyleOptions) { + this.disposeSession(); + const signature = `native:${options.property}:${options.classCount}`; + this.layer.updateStyleVariables(options.variables); + if (signature !== this.templateSignature) { + this.layer.setStyle(options.template); + this.templateSignature = signature; + } + } + + applyRuntime( + options: ApplyStyleOptions, + stateById: ReadonlyMap<string, FeatureStateValue>, + ) { + if (!this.source) return; + const signature = `runtime:${options.property}:${options.classCount}`; + const statePropertyKey = sanitizeStyleIdentifier( + this.stateNamespace + ? `${this.stateNamespace}_${options.property}` + : options.property, + ); + const buildStyle = ( + statePropertyKey: string, + versionKey: string, + version: number, + ) => + buildVersionedFlatStyle( + options.template, + this.defaultStyle, + options.property, + statePropertyKey, + versionKey, + version, + ); + + if (!this.session || this.session.propertyKey !== statePropertyKey) { + this.disposeSession(); + this.session = new VectorTileStyleSession({ + layer: this.layer, + source: this.source, + propertyKey: statePropertyKey, + defaultStyle: this.defaultStyle, + buildStyle, + variables: options.variables, + map: this.map, + buffered: Boolean(this.map), + }); + } else { + this.session.setBuildStyle(buildStyle); + } + this.session.updateStyleVariables(options.variables); + this.templateSignature = signature; + return this.session.commit(stateById); + } + + updateVariables(variables: StyleVariables) { + if (this.session) { + this.session.updateStyleVariables(variables); + } else { + this.layer.updateStyleVariables(variables); + } + } + + hasTemplate() { + return this.templateSignature.length > 0; + } + + reset() { + this.disposeSession(); + this.templateSignature = ""; + this.layer.setStyle(this.defaultStyle); + } + + dispose() { + this.disposeSession(); + } + + private disposeSession() { + this.session?.dispose(); + this.session = null; + } +} diff --git a/src/components/olmap/core/mapLifecycle.test.ts b/src/components/olmap/core/mapLifecycle.test.ts index 38c4056..4a31fc1 100644 --- a/src/components/olmap/core/mapLifecycle.test.ts +++ b/src/components/olmap/core/mapLifecycle.test.ts @@ -91,4 +91,29 @@ describe("map lifecycle", () => { expect(layer.dispose).not.toHaveBeenCalled(); expect(map.dispose).toHaveBeenCalledTimes(1); }); + + it("detaches a comparison map without clearing shared sources", () => { + const source = { clear: jest.fn(), dispose: jest.fn() }; + const layer = { ...createResource(), getSource: () => source }; + const layers = [layer]; + const interactions: ReturnType<typeof createResource>[] = []; + const controls: ReturnType<typeof createResource>[] = []; + const overlays: ReturnType<typeof createResource>[] = []; + const map = { + getLayers: () => createCollection(layers), + removeLayer: (resource: unknown) => layers.splice(layers.indexOf(resource as never), 1), + getInteractions: () => createCollection(interactions), + getControls: () => createCollection(controls), + getOverlays: () => createCollection(overlays), + setTarget: jest.fn(), + dispose: jest.fn(), + } as any; + + disposeMapResources(map, { disposeSources: false }); + + expect(source.clear).not.toHaveBeenCalled(); + expect(source.dispose).not.toHaveBeenCalled(); + expect(layer.dispose).toHaveBeenCalledTimes(1); + expect(map.dispose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/olmap/core/mapLifecycle.ts b/src/components/olmap/core/mapLifecycle.ts index 8e6404c..dba46b4 100644 --- a/src/components/olmap/core/mapLifecycle.ts +++ b/src/components/olmap/core/mapLifecycle.ts @@ -28,25 +28,34 @@ const removeTransientResources = <T extends MapResource>( }); }; -const releaseLayer = (layer: any, dispose: boolean) => { +type ReleaseLayerOptions = { + disposeLayer: boolean; + disposeSource: boolean; +}; + +const releaseLayer = (layer: any, options: ReleaseLayerOptions) => { const childLayers = layer.getLayers?.().getArray?.(); if (Array.isArray(childLayers)) { - [...childLayers].forEach((childLayer) => releaseLayer(childLayer, dispose)); + [...childLayers].forEach((childLayer) => releaseLayer(childLayer, options)); layer.getLayers().clear(); } const source = layer.getSource?.(); - try { - source?.clear?.(); - } catch { - // Some third-party sources do not support explicit cache clearing. - } - if (dispose) { + if (options.disposeSource) { try { - source?.dispose?.(); + source?.clear?.(); } catch { - // Source may already be disposed by its owning layer. + // Some third-party sources do not support explicit cache clearing. } + if (options.disposeLayer) { + try { + source?.dispose?.(); + } catch { + // Source may already be disposed by its owning layer. + } + } + } + if (options.disposeLayer) { try { layer.dispose?.(); } catch { @@ -59,7 +68,7 @@ export const cleanupTransientMapResources = (map: OlMap) => { [...map.getLayers().getArray()].forEach((layer) => { if (isMapResourcePersistent(layer)) return; map.removeLayer(layer); - releaseLayer(layer, true); + releaseLayer(layer, { disposeLayer: true, disposeSource: true }); }); removeTransientResources(map.getInteractions().getArray(), (interaction) => @@ -75,12 +84,16 @@ export const cleanupTransientMapResources = (map: OlMap) => { export const disposeMapResources = ( map: OlMap, - options: { disposeLayers?: boolean } = {}, + options: { disposeLayers?: boolean; disposeSources?: boolean } = {}, ) => { const disposeLayers = options.disposeLayers ?? true; + const disposeSources = options.disposeSources ?? true; [...map.getLayers().getArray()].forEach((layer) => { map.removeLayer(layer); - releaseLayer(layer, disposeLayers); + releaseLayer(layer, { + disposeLayer: disposeLayers, + disposeSource: disposeSources, + }); }); map.getInteractions().clear(); map.getControls().clear(); diff --git a/src/components/olmap/core/operationalLayers.test.ts b/src/components/olmap/core/operationalLayers.test.ts new file mode 100644 index 0000000..5037c27 --- /dev/null +++ b/src/components/olmap/core/operationalLayers.test.ts @@ -0,0 +1,84 @@ +jest.mock("@turf/turf", () => ({ + along: jest.fn(), + lineString: jest.fn(), + length: jest.fn(), + toMercator: jest.fn(), +})); +jest.mock("ol/format/MVT", () => ({ __esModule: true, default: class MVT {} })); +jest.mock("ol/format/GeoJSON", () => ({ __esModule: true, default: class GeoJSON {} })); +jest.mock("ol/geom", () => ({ Point: class Point {} })); +jest.mock("ol/proj", () => ({ toLonLat: jest.fn() })); +jest.mock("ol/style", () => ({ Icon: class Icon {}, Style: class Style {} })); +jest.mock("ol/source/Vector", () => ({ + __esModule: true, + default: class VectorSource { + constructor(readonly options: unknown) {} + }, +})); +jest.mock("ol/source/VectorTile", () => ({ + __esModule: true, + default: class VectorTileSource { + constructor(readonly options: unknown) {} + }, +})); + +jest.mock("ol/layer/Vector", () => ({ + __esModule: true, + default: class MockVectorLayer { + private readonly source: unknown; + private readonly properties = new Map<string, unknown>(); + constructor(options: any) { + this.source = options.source; + Object.entries(options.properties || {}).forEach(([key, value]) => + this.properties.set(key, value), + ); + } + getSource() { return this.source; } + get(key: string) { return this.properties.get(key); } + set(key: string, value: unknown) { this.properties.set(key, value); } + setStyle() {} + }, +})); +jest.mock("ol/layer/WebGLVectorTile", () => ({ + __esModule: true, + default: class MockWebGlVectorTileLayer { + private readonly source: unknown; + private readonly properties = new Map<string, unknown>(); + constructor(options: any) { + this.source = options.source; + Object.entries(options.properties || {}).forEach(([key, value]) => + this.properties.set(key, value), + ); + } + getSource() { return this.source; } + get(key: string) { return this.properties.get(key); } + set(key: string, value: unknown) { this.properties.set(key, value); } + setStyle() {} + }, +})); + +import { + createOperationalMapResources, + createOperationalMapSources, +} from "./operationalLayers"; + +describe("operational map resources", () => { + it("shares sources while keeping per-map layer instances independent", () => { + const options = { + mapUrl: "https://maps.example.test/geoserver", + workspace: "test_workspace", + extent: [0, 0, 100, 100] as [number, number, number, number], + }; + const sources = createOperationalMapSources(options); + const primary = createOperationalMapResources({ ...options, sources }); + const compare = createOperationalMapResources({ ...options, sources }); + + Object.keys(sources).forEach((sourceId) => { + const key = sourceId as keyof typeof sources; + expect(primary.sources[key]).toBe(compare.sources[key]); + expect(primary.layers[key]).not.toBe(compare.layers[key]); + expect(primary.layers[key].getSource()).toBe(sources[key]); + expect(compare.layers[key].getSource()).toBe(sources[key]); + }); + }); +}); diff --git a/src/components/olmap/core/operationalLayers.ts b/src/components/olmap/core/operationalLayers.ts index 7f5ecba..0a2a128 100644 --- a/src/components/olmap/core/operationalLayers.ts +++ b/src/components/olmap/core/operationalLayers.ts @@ -17,11 +17,25 @@ import { markMapResourcePersistent } from "./mapLifecycle"; type MapExtent = [number, number, number, number]; -type CreateOperationalLayersOptions = { +type CreateOperationalMapSourcesOptions = { mapUrl: string; workspace: string; +}; + +type CreateOperationalLayersOptions = CreateOperationalMapSourcesOptions & { extent: MapExtent; persistent?: boolean; + sources?: OperationalMapSources; +}; + +export type OperationalMapSources = { + junctions: VectorTileSource; + pipes: VectorTileSource; + valves: VectorTileSource; + reservoirs: VectorSource; + pumps: VectorSource; + tanks: VectorSource; + scada: VectorSource; }; const defaultFlatStyle = config.MAP_DEFAULT_STYLE as FlatStyleLike; @@ -85,18 +99,16 @@ const pipeProperties = [ { name: "流速", value: "velocity" }, ]; -export const createOperationalMapResources = ({ +export const createOperationalMapSources = ({ mapUrl, workspace, - extent, - persistent = false, -}: CreateOperationalLayersOptions) => { +}: CreateOperationalMapSourcesOptions): OperationalMapSources => { const vectorTileUrl = (name: string) => `${mapUrl}/gwc/service/tms/1.0.0/${workspace}:${name}@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`; const vectorUrl = (name: string) => `${mapUrl}/${workspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${workspace}:${name}&outputFormat=application/json`; - const sources = { + return { junctions: new VectorTileSource({ url: vectorTileUrl("geo_junctions"), format: new MVT(), @@ -129,6 +141,15 @@ export const createOperationalMapResources = ({ format: new GeoJSON(), }), }; +}; + +export const createOperationalMapResources = ({ + mapUrl, + workspace, + extent, + persistent = false, + sources = createOperationalMapSources({ mapUrl, workspace }), +}: CreateOperationalLayersOptions) => { const layers = { junctions: new WebGLVectorTileLayer({ diff --git a/src/components/olmap/core/tileFeatureIndex.test.ts b/src/components/olmap/core/tileFeatureIndex.test.ts new file mode 100644 index 0000000..617533d --- /dev/null +++ b/src/components/olmap/core/tileFeatureIndex.test.ts @@ -0,0 +1,101 @@ +jest.mock("ol/extent", () => ({ + intersects: (a: number[], b: number[]) => + a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1], +})); + +jest.mock("ol/proj", () => ({ + toLonLat: (coordinate: number[]) => coordinate, +})); + +import { + TileFeatureIndex, + clipLineStringPartsToExtent, + lineStringFromFlatCoordinates, +} from "./tileFeatureIndex"; + +describe("tileFeatureIndex geometry helpers", () => { + it("clips a line to the tile core extent without dropping both sides", () => { + const clipped = clipLineStringPartsToExtent( + [ + [-10, 5], + [5, 5], + [15, 5], + ], + [0, 0, 10, 10], + ); + + expect(clipped).toEqual([ + [ + [0, 5], + [5, 5], + [10, 5], + ], + ]); + }); + + it("preserves all coordinates from a flat line with stride", () => { + expect(lineStringFromFlatCoordinates([0, 1, 2, 3, 4, 5], 2)).toEqual([ + [0, 1], + [2, 3], + [4, 5], + ]); + }); + + it("keeps disconnected clipped line parts separate", () => { + expect( + clipLineStringPartsToExtent( + [ + [-5, 2], + [5, 2], + [15, 2], + [15, 8], + [5, 8], + [-5, 8], + ], + [0, 0, 10, 10], + ), + ).toEqual([ + [ + [0, 2], + [5, 2], + [10, 2], + ], + [ + [10, 8], + [5, 8], + [0, 8], + ], + ]); + }); + + it("keeps multiple tile instances for the same feature id", () => { + const makeFeature = (flatCoordinates: number[]) => ({ + getProperties: () => ({ id: "P-1", diameter: 100 }), + getGeometry: () => ({ + getType: () => "LineString", + getFlatCoordinates: () => flatCoordinates, + getStride: () => 2, + }), + }); + const makeTile = (x: number, flatCoordinates: number[]) => ({ + getTileCoord: () => [14, x, 5], + getFeatures: () => [makeFeature(flatCoordinates)], + }); + const source = { + sourceTiles_: { + a: makeTile(1, [0, 0, 10, 0]), + b: makeTile(2, [10, 0, 20, 0]), + }, + getTileGrid: () => ({ + getTileCoordExtent: ([, x]: [number, number, number]) => + x === 1 ? [0, -10, 10, 10] : [10, -10, 20, 10], + }), + } as any; + + const index = new TileFeatureIndex("pipes", source); + const snapshot = index.getSnapshot(undefined, 14); + + expect(snapshot.instances).toHaveLength(2); + expect(snapshot.instancesById.get("P-1")).toHaveLength(2); + }); +}); diff --git a/src/components/olmap/core/tileFeatureIndex.ts b/src/components/olmap/core/tileFeatureIndex.ts new file mode 100644 index 0000000..c941076 --- /dev/null +++ b/src/components/olmap/core/tileFeatureIndex.ts @@ -0,0 +1,322 @@ +import { intersects } from "ol/extent"; +import type { Extent } from "ol/extent"; +import type OlMap from "ol/Map"; +import { toLonLat } from "ol/proj"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { Coordinate } from "ol/coordinate"; +import { + getLoadedVectorTiles, + getVectorTileFeatureId, + getVectorTileFeatureProperties, +} from "./vectorTileUtils"; + +type TileKey = string; + +export type TileFeatureInstance = { + instanceKey: string; + z: number; + featureId: string; + properties: Record<string, any>; + geometryType: string; + tileExtent: Extent; + flatCoordinates: number[]; + stride: number; +}; + +type TileFeatureSnapshot = { + instancesById: Map<string, TileFeatureInstance[]>; + instances: TileFeatureInstance[]; +}; + +const getTileCoord = (tile: any): [number, number, number] | null => { + const coord = + typeof tile.getTileCoord === "function" + ? tile.getTileCoord() + : tile.tileCoord ?? tile.tileCoord_; + if (!Array.isArray(coord) || coord.length < 3) return null; + return [Number(coord[0]), Number(coord[1]), Number(coord[2])]; +}; + +const getTileKey = (sourceKey: string, z: number, x: number, y: number) => + `${sourceKey}/${z}/${x}/${y}`; + +const getInstanceKey = ( + sourceKey: string, + z: number, + x: number, + y: number, + featureOrdinal: number, +) => `${sourceKey}/${z}/${x}/${y}/${featureOrdinal}`; + +const normalizeExtent = (extent: Extent): Extent => [ + Math.min(extent[0], extent[2]), + Math.min(extent[1], extent[3]), + Math.max(extent[0], extent[2]), + Math.max(extent[1], extent[3]), +]; + +const getTileGrid = (source: VectorTileSource, map?: OlMap) => { + const sourceAny = source as any; + const projection = map?.getView().getProjection(); + if (projection && typeof sourceAny.getTileGridForProjection === "function") { + return sourceAny.getTileGridForProjection(projection); + } + return typeof sourceAny.getTileGrid === "function" + ? sourceAny.getTileGrid() + : sourceAny.tileGrid ?? sourceAny.tileGrid_; +}; + +const getTileExtent = ( + source: VectorTileSource, + tile: any, + z: number, + x: number, + y: number, +): Extent | null => { + const tileGrid = getTileGrid(source); + if (tileGrid && typeof tileGrid.getTileCoordExtent === "function") { + return normalizeExtent(tileGrid.getTileCoordExtent([z, x, y]) as Extent); + } + const extent = + typeof tile.getExtent === "function" + ? tile.getExtent() + : tile.extent ?? tile.extent_; + return Array.isArray(extent) ? normalizeExtent(extent as Extent) : null; +}; + +const getFeatureGeometry = (renderFeature: any) => { + if (typeof renderFeature.getGeometry === "function") { + return renderFeature.getGeometry(); + } + return renderFeature; +}; + +const getGeometryType = (geometry: any): string => { + if (typeof geometry.getType === "function") return String(geometry.getType()); + if (typeof geometry.getType === "string") return geometry.getType; + return ""; +}; + +const getFlatCoordinates = (geometry: any): number[] => { + if (typeof geometry.getFlatCoordinates === "function") { + return Array.from(geometry.getFlatCoordinates() ?? []); + } + if (Array.isArray(geometry.flatCoordinates)) { + return geometry.flatCoordinates.slice(); + } + if (Array.isArray(geometry.flatCoordinates_)) { + return geometry.flatCoordinates_.slice(); + } + return []; +}; + +const getStride = (geometry: any) => + typeof geometry.getStride === "function" + ? Number(geometry.getStride()) + : Number(geometry.stride ?? geometry.stride_ ?? 2); + +export const lineStringFromFlatCoordinates = ( + flatCoordinates: number[], + stride: number, +): Coordinate[] => { + const coordinates: Coordinate[] = []; + for (let i = 0; i + 1 < flatCoordinates.length; i += stride) { + coordinates.push([flatCoordinates[i], flatCoordinates[i + 1]]); + } + return coordinates; +}; + +const clipSegmentToExtent = ( + start: Coordinate, + end: Coordinate, + extent: Extent, +): [Coordinate, Coordinate] | null => { + const [minX, minY, maxX, maxY] = extent; + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + let t0 = 0; + let t1 = 1; + + const update = (p: number, q: number) => { + if (p === 0) return q >= 0; + const r = q / p; + if (p < 0) { + if (r > t1) return false; + if (r > t0) t0 = r; + } else { + if (r < t0) return false; + if (r < t1) t1 = r; + } + return true; + }; + + if ( + !update(-dx, start[0] - minX) || + !update(dx, maxX - start[0]) || + !update(-dy, start[1] - minY) || + !update(dy, maxY - start[1]) + ) { + return null; + } + + return [ + [start[0] + t0 * dx, start[1] + t0 * dy], + [start[0] + t1 * dx, start[1] + t1 * dy], + ]; +}; + +const sameCoordinate = (a: Coordinate, b: Coordinate) => + a[0] === b[0] && a[1] === b[1]; + +export const clipLineStringPartsToExtent = ( + coordinates: Coordinate[], + extent: Extent, +): Coordinate[][] => { + const parts: Coordinate[][] = []; + let currentPart: Coordinate[] = []; + for (let i = 1; i < coordinates.length; i += 1) { + const segment = clipSegmentToExtent( + coordinates[i - 1], + coordinates[i], + extent, + ); + if (!segment) { + if (currentPart.length >= 2) parts.push(currentPart); + currentPart = []; + continue; + } + const [start, end] = segment; + if ( + currentPart.length > 0 && + !sameCoordinate(currentPart[currentPart.length - 1], start) + ) { + if (currentPart.length >= 2) parts.push(currentPart); + currentPart = []; + } + if ( + currentPart.length === 0 || + !sameCoordinate(currentPart[currentPart.length - 1], start) + ) { + currentPart.push(start); + } + if (!sameCoordinate(start, end)) { + currentPart.push(end); + } + } + if (currentPart.length >= 2) parts.push(currentPart); + return parts; +}; + +export const coordinatesToLonLat = ( + coordinates: Coordinate[], +): [number, number][] => + coordinates.map((coordinate) => { + const [lon, lat] = toLonLat(coordinate); + return [lon, lat]; + }); + +export class TileFeatureIndex { + private readonly instancesByTile = new Map< + TileKey, + TileFeatureInstance[] + >(); + + constructor( + private readonly sourceKey: string, + private readonly source: VectorTileSource, + ) { + this.scanLoadedTiles(); + } + + scanLoadedTiles() { + const activeTileKeys = new Set<TileKey>(); + getLoadedVectorTiles(this.source).forEach((tile) => { + const tileKey = this.registerTile(tile); + if (tileKey) activeTileKeys.add(tileKey); + }); + this.pruneMissingTiles(activeTileKeys); + } + + registerTile(tile: any): TileKey | null { + if (typeof tile?.getFeatures !== "function") return null; + const coord = getTileCoord(tile); + if (!coord) return null; + const [z, x, y] = coord; + const extent = getTileExtent(this.source, tile, z, x, y); + if (!extent) return null; + + const tileKey = getTileKey(this.sourceKey, z, x, y); + const tileInstances: TileFeatureInstance[] = []; + const renderFeatures = tile.getFeatures() ?? []; + renderFeatures.forEach((renderFeature: any, featureOrdinal: number) => { + const properties = getVectorTileFeatureProperties(renderFeature); + const featureId = getVectorTileFeatureId(renderFeature); + if (!featureId) return; + const geometry = getFeatureGeometry(renderFeature); + if (!geometry) return; + const flatCoordinates = getFlatCoordinates(geometry); + const stride = getStride(geometry); + if (flatCoordinates.length < 2 || stride < 2) return; + + const instanceKey = getInstanceKey( + this.sourceKey, + z, + x, + y, + featureOrdinal, + ); + tileInstances.push({ + instanceKey, + z, + featureId, + properties, + geometryType: getGeometryType(geometry), + tileExtent: extent, + flatCoordinates, + stride, + }); + }); + + if (tileInstances.length === 0) { + this.instancesByTile.delete(tileKey); + } else { + this.instancesByTile.set(tileKey, tileInstances); + } + return tileKey; + } + + private pruneMissingTiles(activeTileKeys: Set<TileKey>) { + Array.from(this.instancesByTile.keys()).forEach((tileKey) => { + if (!activeTileKeys.has(tileKey)) { + this.instancesByTile.delete(tileKey); + } + }); + } + + getSnapshot(map: OlMap | undefined, zoom: number): TileFeatureSnapshot { + const tileGrid = getTileGrid(this.source, map); + const resolution = map?.getView().getResolution(); + const targetZ = + tileGrid && resolution !== undefined + ? tileGrid.getZForResolution(resolution, (this.source as any).zDirection) + : Math.max(0, Math.round(zoom)); + const viewExtent = map?.getView().calculateExtent(map.getSize()); + const instances = Array.from(this.instancesByTile.values()) + .flat() + .filter((instance) => instance.z === targetZ) + .filter( + (instance) => + !viewExtent || intersects(instance.tileExtent, viewExtent), + ) + .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)); + + const instancesById = new Map<string, TileFeatureInstance[]>(); + instances.forEach((instance) => { + const featureInstances = instancesById.get(instance.featureId); + if (featureInstances) featureInstances.push(instance); + else instancesById.set(instance.featureId, [instance]); + }); + + return { instancesById, instances }; + } +} diff --git a/src/components/olmap/core/vectorTileStyleSession.test.ts b/src/components/olmap/core/vectorTileStyleSession.test.ts new file mode 100644 index 0000000..29a6c6d --- /dev/null +++ b/src/components/olmap/core/vectorTileStyleSession.test.ts @@ -0,0 +1,245 @@ +jest.mock("ol/layer/WebGLVectorTile", () => ({ + __esModule: true, + default: class WebGLVectorTileLayer { + opacity: number; + visible: boolean; + renderer = { renderComplete: true }; + source: any; + style: any; + properties: Record<string, unknown>; + constructor(options: any = {}) { + this.opacity = options.opacity ?? 1; + this.visible = options.visible ?? true; + this.source = options.source; + this.style = options.style; + this.properties = options.properties ?? {}; + } + getOpacity() { return this.opacity; } + setOpacity(value: number) { this.opacity = value; } + getVisible() { return this.visible; } + setVisible(value: boolean) { this.visible = value; } + getExtent() { return undefined; } + getMinZoom() { return 0; } + getMaxZoom() { return 24; } + getMinResolution() { return 0; } + getMaxResolution() { return Infinity; } + getPreload() { return 0; } + setPreload() {} + getZIndex() { return undefined; } + setZIndex() {} + on() {} + un() {} + setStyle(style: any) { + this.style = style; + this.renderer = { renderComplete: true }; + } + updateStyleVariables() {} + getRenderer() { return this.renderer; } + dispose() {} + }, +})); + +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import { VectorTileStyleSession } from "./vectorTileStyleSession"; + +const makeFeature = (id: string) => { + const properties: Record<string, unknown> = { id }; + return { + properties, + properties_: properties, + get: (key: string) => properties[key], + }; +}; + +const makeTile = (...features: ReturnType<typeof makeFeature>[]) => ({ + getFeatures: () => features, +}); + +describe("VectorTileStyleSession", () => { + it("fans one feature state out to every loaded and future tile instance", () => { + const featureA = makeFeature("P-1"); + const featureB = makeFeature("P-1"); + const listeners = new Set<(event: any) => void>(); + const source = { + sourceTiles_: { + a: makeTile(featureA), + b: makeTile(featureB), + }, + on: (_type: string, listener: (event: any) => void) => listeners.add(listener), + un: (_type: string, listener: (event: any) => void) => listeners.delete(listener), + } as any; + const layer = { + on: jest.fn(), + un: jest.fn(), + getOpacity: () => 1, + setOpacity: jest.fn(), + setStyle: jest.fn(), + } as any; + const session = new VectorTileStyleSession({ + layer, + source, + propertyKey: "healthRisk", + defaultStyle: { "stroke-color": "blue" }, + buildStyle: () => ({ "stroke-color": "red" }), + }); + + session.commit(new Map([["P-1", 0.25]])); + + expect(featureA.properties.healthRisk).toBe(0.25); + expect(featureB.properties.healthRisk).toBe(0.25); + expect(layer.setStyle).toHaveBeenCalledTimes(1); + + const futureFeature = makeFeature("P-1"); + listeners.forEach((listener) => listener({ tile: makeTile(futureFeature) })); + expect(futureFeature.properties.healthRisk).toBe(0.25); + + session.commit(new Map()); + expect(featureA.properties).not.toHaveProperty("healthRisk"); + expect(featureB.properties).not.toHaveProperty("healthRisk"); + + session.dispose(); + expect(listeners.size).toBe(0); + }); + + it("keeps the active layer visible until the standby renderer is ready", async () => { + const feature = makeFeature("P-1"); + const listeners = new Set<(event: any) => void>(); + const source = { + sourceTiles_: { a: makeTile(feature) }, + on: (_type: string, listener: (event: any) => void) => listeners.add(listener), + un: (_type: string, listener: (event: any) => void) => listeners.delete(listener), + } as any; + const baseLayer = new WebGLVectorTileLayer({ + source, + style: { "stroke-color": "blue" }, + }) as any; + const layers = [baseLayer]; + const postrenderListeners = new Set<() => void>(); + const map = { + getLayers: () => ({ + getArray: () => layers, + getLength: () => layers.length, + insertAt: (index: number, layer: any) => layers.splice(index, 0, layer), + }), + removeLayer: (layer: any) => { + const index = layers.indexOf(layer); + if (index >= 0) layers.splice(index, 1); + }, + on: (_type: string, listener: () => void) => postrenderListeners.add(listener), + un: (_type: string, listener: () => void) => postrenderListeners.delete(listener), + render: () => postrenderListeners.forEach((listener) => listener()), + } as any; + const styleKeys: string[] = []; + const session = new VectorTileStyleSession({ + layer: baseLayer, + source, + map, + buffered: true, + propertyKey: "healthRisk", + defaultStyle: { "stroke-color": "blue" }, + buildStyle: (propertyKey, versionKey) => { + styleKeys.push(propertyKey, versionKey); + return { "stroke-color": "red" }; + }, + }); + + const commit = session.commit(new Map([["P-1", 0.25]])); + expect(baseLayer.getOpacity()).toBe(1); + await commit; + + expect(layers).toHaveLength(2); + expect(baseLayer.getOpacity()).toBe(0); + expect(layers[1].getOpacity()).toBe(1); + expect(styleKeys).toEqual([ + "healthRisk_buffer_1", + "healthRisk_render_version_buffer_1", + ]); + expect(styleKeys.every((key) => !key.includes("__"))).toBe(true); + session.dispose(); + expect(layers).toHaveLength(1); + }); + + it("does not replace a renderer while an obsolete buffer is still building", async () => { + const feature = makeFeature("P-1"); + const source = { + sourceTiles_: { a: makeTile(feature) }, + on: jest.fn(), + un: jest.fn(), + } as any; + const baseLayer = new WebGLVectorTileLayer({ + source, + style: { "stroke-color": "blue" }, + }) as any; + const layers = [baseLayer]; + const postrenderListeners = new Set<() => void>(); + const map = { + getLayers: () => ({ + getArray: () => layers, + getLength: () => layers.length, + insertAt: (index: number, layer: any) => layers.splice(index, 0, layer), + }), + removeLayer: jest.fn(), + on: (_type: string, listener: () => void) => postrenderListeners.add(listener), + un: (_type: string, listener: () => void) => postrenderListeners.delete(listener), + render: jest.fn(), + } as any; + const session = new VectorTileStyleSession({ + layer: baseLayer, + source, + map, + buffered: true, + propertyKey: "healthRisk", + defaultStyle: { "stroke-color": "blue" }, + buildStyle: () => ({ "stroke-color": "red" }), + }); + + const firstCommit = session.commit(new Map([["P-1", 0.25]])); + await Promise.resolve(); + await Promise.resolve(); + const standbyLayer = layers[1] as any; + standbyLayer.renderer = { renderComplete: false }; + const setStyle = jest.spyOn(standbyLayer, "setStyle"); + + const latestCommit = session.commit(new Map([["P-1", 0.75]])); + expect(setStyle).not.toHaveBeenCalled(); + + postrenderListeners.forEach((listener) => listener()); + await expect(firstCommit).resolves.toBe(false); + await Promise.resolve(); + await Promise.resolve(); + expect(setStyle).toHaveBeenCalledTimes(1); + + postrenderListeners.forEach((listener) => listener()); + postrenderListeners.forEach((listener) => listener()); + await expect(latestCommit).resolves.toBe(true); + expect(feature.properties.healthRisk_buffer_1).toBe(0.75); + }); + + it("normalizes generated shader identifiers and mirrors variable updates", () => { + const source = { sourceTiles_: {}, on: jest.fn(), un: jest.fn() } as any; + const layer = { + on: jest.fn(), + un: jest.fn(), + getOpacity: () => 1, + setOpacity: jest.fn(), + setStyle: jest.fn(), + updateStyleVariables: jest.fn(), + } as any; + const keys: string[] = []; + const session = new VectorTileStyleSession({ + layer, + source, + propertyKey: "healthRisk__unsafe", + defaultStyle: { "stroke-color": "gray" }, + buildStyle: (propertyKey, versionKey) => { + keys.push(propertyKey, versionKey); + return { "stroke-color": "red" }; + }, + }); + session.updateStyleVariables({ tj_color_0: "red" }); + session.commit(new Map()); + + expect(layer.updateStyleVariables).toHaveBeenCalledWith({ tj_color_0: "red" }); + expect(keys.every((key) => !key.includes("__"))).toBe(true); + }); +}); diff --git a/src/components/olmap/core/vectorTileStyleSession.ts b/src/components/olmap/core/vectorTileStyleSession.ts new file mode 100644 index 0000000..47756f8 --- /dev/null +++ b/src/components/olmap/core/vectorTileStyleSession.ts @@ -0,0 +1,414 @@ +import type OlMap from "ol/Map"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { FlatStyleLike, StyleVariables } from "ol/style/flat"; +import { + getLoadedVectorTiles, + getVectorTileFeatureId, + getVectorTileFeatureProperties, +} from "./vectorTileUtils"; + +type FeatureStateValue = string | number | boolean | null; +type StyleBuilder = ( + propertyKey: string, + versionKey: string, + version: number, +) => FlatStyleLike; + +type VectorTileStyleSessionOptions = { + layer: WebGLVectorTileLayer; + source: VectorTileSource; + propertyKey: string; + defaultStyle: FlatStyleLike; + buildStyle: StyleBuilder; + map?: OlMap; + buffered?: boolean; + variables?: StyleVariables; +}; + +const INTERNAL_BUFFER_LAYER = "internalRenderBuffer"; +const BUFFER_READY_TIMEOUT_MS = 5000; + +export class VectorTileStyleSession { + private readonly layer: WebGLVectorTileLayer; + private readonly source: VectorTileSource; + readonly propertyKey: string; + private readonly versionKey: string; + private readonly defaultStyle: FlatStyleLike; + private readonly map?: OlMap; + private readonly buffered: boolean; + private buildStyle: StyleBuilder; + private styleVariables: StyleVariables; + private unbufferedState = new Map<string, FeatureStateValue>(); + private version = 0; + private disposed = false; + private commitRevision = 0; + private bufferedCommitQueue: Promise<void> = Promise.resolve(); + private readonly slotStates = [ + new Map<string, FeatureStateValue>(), + new Map<string, FeatureStateValue>(), + ]; + private readonly slotVersions = [0, 0]; + private activeSlot = 0; + private activeLayer: WebGLVectorTileLayer; + private bufferLayer: WebGLVectorTileLayer | null = null; + private readonly displayOpacity: number; + private readonly listener: (event: any) => void; + private readonly visibilityListener: () => void; + + constructor(options: VectorTileStyleSessionOptions) { + this.layer = options.layer; + this.source = options.source; + this.propertyKey = sanitizeStyleIdentifier(options.propertyKey); + this.versionKey = `${this.propertyKey}_render_version`; + this.defaultStyle = options.defaultStyle; + this.buildStyle = options.buildStyle; + this.styleVariables = { ...(options.variables || {}) }; + this.map = options.map; + this.buffered = Boolean(options.buffered && options.map); + this.activeLayer = this.layer; + this.displayOpacity = this.layer.getOpacity(); + this.listener = (event: any) => { + try { + if (typeof event.tile?.getFeatures !== "function") return; + this.applyTile(event.tile); + } catch (error) { + console.error("Vector tile style session load error:", error); + } + }; + this.visibilityListener = () => { + this.bufferLayer?.setVisible(this.layer.getVisible()); + }; + this.source.on("tileloadend", this.listener); + this.layer.on("change:visible", this.visibilityListener); + } + + commit( + stateById: ReadonlyMap<string, FeatureStateValue>, + ): Promise<boolean> | void { + if (this.disposed) return; + if (this.buffered) { + return this.commitBuffered(stateById, false); + } + + this.version += 1; + this.unbufferedState = this.normalizeState(stateById); + this.applyLoadedTiles(); + this.layer.setStyle( + this.buildStyle(this.propertyKey, this.versionKey, this.version), + ); + } + + setBuildStyle(buildStyle: StyleBuilder) { + this.buildStyle = buildStyle; + } + + updateStyleVariables(variables: StyleVariables) { + if (this.disposed) return; + this.styleVariables = { ...this.styleVariables, ...variables }; + this.layer.updateStyleVariables(variables); + this.bufferLayer?.updateStyleVariables(variables); + } + + reset(): Promise<boolean> | void { + if (this.disposed) return; + if (this.buffered) { + return this.commitBuffered(new Map(), true); + } + this.version += 1; + this.unbufferedState = new Map(); + this.applyLoadedTiles(); + this.layer.setStyle(this.defaultStyle); + } + + dispose() { + if (this.disposed) return; + this.commitRevision += 1; + this.source.un("tileloadend", this.listener); + this.layer.un("change:visible", this.visibilityListener); + if (this.bufferLayer && this.map) { + this.map.removeLayer(this.bufferLayer); + this.bufferLayer.dispose(); + this.bufferLayer = null; + } + this.layer.setOpacity(this.displayOpacity); + this.disposed = true; + } + + private normalizeState(stateById: ReadonlyMap<string, FeatureStateValue>) { + return new Map( + Array.from(stateById.entries()).map(([id, value]) => [String(id), value]), + ); + } + + private getSlotPropertyKey(slot: number) { + return `${this.propertyKey}_buffer_${slot}`; + } + + private getSlotVersionKey(slot: number) { + return `${this.versionKey}_buffer_${slot}`; + } + + private commitBuffered( + stateById: ReadonlyMap<string, FeatureStateValue>, + useDefaultStyle: boolean, + ) { + if (!this.map || this.disposed) return Promise.resolve(false); + const revision = this.commitRevision + 1; + this.commitRevision = revision; + const normalizedState = this.normalizeState(stateById); + const commitPromise = this.bufferedCommitQueue.then(() => { + if (this.disposed || revision !== this.commitRevision) return false; + return this.prepareBufferedCommit( + normalizedState, + useDefaultStyle, + revision, + ); + }); + this.bufferedCommitQueue = commitPromise.then( + () => undefined, + () => undefined, + ); + + // Wake a pending readiness check so an obsolete build can exit before the + // next style replaces its renderer. + this.map.render(); + return commitPromise; + } + + private async prepareBufferedCommit( + stateById: Map<string, FeatureStateValue>, + useDefaultStyle: boolean, + revision: number, + ) { + if (!this.map || this.disposed || revision !== this.commitRevision) { + return false; + } + const targetSlot = this.activeSlot === 0 ? 1 : 0; + this.version += 1; + this.slotVersions[targetSlot] = this.version; + this.slotStates[targetSlot] = stateById; + this.applyLoadedTiles(); + + const standbyLayer = this.getStandbyLayer(); + standbyLayer.setVisible(this.layer.getVisible()); + standbyLayer.setOpacity(0); + standbyLayer.setStyle( + useDefaultStyle + ? this.defaultStyle + : this.buildStyle( + this.getSlotPropertyKey(targetSlot), + this.getSlotVersionKey(targetSlot), + this.version, + ), + ); + this.map.render(); + + const ready = await this.waitUntilReady(standbyLayer, revision); + if (!ready || this.disposed || revision !== this.commitRevision) { + return false; + } + + const previousLayer = this.activeLayer; + standbyLayer.setOpacity(this.displayOpacity); + previousLayer.setOpacity(0); + this.activeLayer = standbyLayer; + this.activeSlot = targetSlot; + this.map.render(); + return true; + } + + private getStandbyLayer(): WebGLVectorTileLayer { + const map = this.map; + if (!map) return this.layer; + if (!this.bufferLayer) { + const bufferLayer = new WebGLVectorTileLayer<any, any>({ + source: this.source, + style: this.defaultStyle, + extent: this.layer.getExtent(), + minZoom: this.layer.getMinZoom(), + maxZoom: this.layer.getMaxZoom(), + minResolution: this.layer.getMinResolution(), + maxResolution: this.layer.getMaxResolution(), + opacity: 0, + visible: this.layer.getVisible(), + variables: this.styleVariables, + properties: { + [INTERNAL_BUFFER_LAYER]: true, + queryable: false, + }, + }); + bufferLayer.setPreload(this.layer.getPreload()); + const zIndex = this.layer.getZIndex(); + if (zIndex !== undefined) bufferLayer.setZIndex(zIndex); + const layers = map.getLayers(); + const baseIndex = layers.getArray().indexOf(this.layer); + layers.insertAt(baseIndex >= 0 ? baseIndex + 1 : layers.getLength(), bufferLayer); + this.bufferLayer = bufferLayer; + } + return this.activeLayer === this.layer ? this.bufferLayer! : this.layer; + } + + private waitUntilReady(layer: WebGLVectorTileLayer, revision: number) { + if (!this.map) return Promise.resolve(true); + return new Promise<boolean>((resolve) => { + let consecutiveReadyFrames = 0; + let settled = false; + const finish = (ready: boolean) => { + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + this.map?.un("postrender", checkReady); + resolve(ready); + }; + const checkReady = () => { + if (this.disposed || revision !== this.commitRevision) { + finish(false); + return; + } + const renderer = layer.getRenderer() as any; + consecutiveReadyFrames = renderer?.renderComplete + ? consecutiveReadyFrames + 1 + : 0; + if (consecutiveReadyFrames >= 2) { + finish(true); + return; + } + this.map?.render(); + }; + const timeoutId = window.setTimeout( + () => finish(false), + BUFFER_READY_TIMEOUT_MS, + ); + this.map!.on("postrender", checkReady); + this.map!.render(); + }); + } + + private applyLoadedTiles() { + getLoadedVectorTiles(this.source).forEach((tile) => this.applyTile(tile)); + } + + private applyTile(tile: any) { + if (this.buffered) { + this.slotVersions.forEach((version, slot) => { + if (version > 0) { + this.applyTileState( + tile, + this.slotStates[slot], + this.getSlotPropertyKey(slot), + this.getSlotVersionKey(slot), + version, + ); + } + }); + return; + } + this.applyTileState( + tile, + this.unbufferedState, + this.propertyKey, + this.versionKey, + this.version, + ); + } + + private applyTileState( + tile: any, + stateById: ReadonlyMap<string, FeatureStateValue>, + propertyKey: string, + versionKey: string, + version: number, + ) { + const renderFeatures = tile.getFeatures?.(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + const featureId = getVectorTileFeatureId(renderFeature); + if (!featureId) return; + const properties = getVectorTileFeatureProperties(renderFeature); + const value = stateById.get(featureId); + if (value === undefined) { + delete properties[propertyKey]; + properties[versionKey] = version; + return; + } + properties[propertyKey] = value; + properties[versionKey] = version; + }); + } +} + +export const sanitizeStyleIdentifier = (value: string) => { + const normalized = value + .replace(/[^A-Za-z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const prefixed = /^[A-Za-z]/.test(normalized) ? normalized : `tj_${normalized}`; + return prefixed || "tj_state"; +}; + +export const versionedPropertyCase = ( + propertyKey: string, + versionKey: string, + version: number, + cases: any[], + fallback: any, +) => [ + "case", + ["all", ["==", ["get", versionKey], version], ["has", propertyKey]], + ["case", ...cases, fallback], + fallback, +]; + +const remapFlatStyleProperty = ( + style: FlatStyleLike, + fromProperty: string, + toProperty: string, +): FlatStyleLike => { + const remap = (value: any): any => { + if (Array.isArray(value)) { + const next = value.map(remap); + if ( + (next[0] === "get" || next[0] === "has") && + next[1] === fromProperty + ) { + next[1] = toProperty; + } + return next; + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, remap(nested)]), + ); + } + return value; + }; + return remap(style) as FlatStyleLike; +}; + +export const buildVersionedFlatStyle = ( + style: FlatStyleLike, + fallbackStyle: FlatStyleLike, + sourcePropertyKey: string, + propertyKey: string, + versionKey: string, + version: number, +): FlatStyleLike => { + const remappedStyle = remapFlatStyleProperty( + style, + sourcePropertyKey, + propertyKey, + ); + const guarded: Record<string, any> = {}; + Object.entries(remappedStyle as Record<string, any>).forEach( + ([key, value]) => { + guarded[key] = [ + "case", + ["all", ["==", ["get", versionKey], version], ["has", propertyKey]], + value, + (fallbackStyle as Record<string, any>)[key] ?? value, + ]; + }, + ); + return guarded as FlatStyleLike; +}; diff --git a/src/components/olmap/core/vectorTileUtils.ts b/src/components/olmap/core/vectorTileUtils.ts new file mode 100644 index 0000000..640752a --- /dev/null +++ b/src/components/olmap/core/vectorTileUtils.ts @@ -0,0 +1,24 @@ +import type VectorTileSource from "ol/source/VectorTile"; + +export const getLoadedVectorTiles = (source: VectorTileSource): any[] => { + const sourceTiles = (source as any).sourceTiles_; + if (!sourceTiles) return []; + return sourceTiles instanceof Map + ? Array.from(sourceTiles.values()) + : Object.values(sourceTiles); +}; + +export const getVectorTileFeatureProperties = ( + feature: any, +): Record<string, any> => + feature.properties_ ?? feature.getProperties?.() ?? {}; + +export const getVectorTileFeatureId = (feature: any): string | null => { + const properties = getVectorTileFeatureProperties(feature); + const id = + feature.get?.("id") ?? + feature.get?.("ID") ?? + properties.id ?? + properties.ID; + return id === undefined || id === null || id === "" ? null : String(id); +}; -- 2.54.0 From 08152ff97801b5a870e1d5cd3134d569bfcb4228 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 15:28:44 +0800 Subject: [PATCH 234/281] fix(map): restore saved styles as inactive drafts --- .../core/Controls/styleEditorUtils.test.ts | 23 +++++++++++ .../olmap/core/Controls/styleEditorUtils.ts | 41 +++++++++++++++++++ .../olmap/core/Controls/useStyleEditor.ts | 34 ++++----------- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/components/olmap/core/Controls/styleEditorUtils.test.ts b/src/components/olmap/core/Controls/styleEditorUtils.test.ts index 3237e56..c7ce0f6 100644 --- a/src/components/olmap/core/Controls/styleEditorUtils.test.ts +++ b/src/components/olmap/core/Controls/styleEditorUtils.test.ts @@ -3,8 +3,10 @@ import { buildDynamicStyleTemplate, buildStyleVariables, getDefaultCustomBreaks, + hydrateStoredLayerStyleStates, resolveLayerStyle, requiresStyleApply, + selectStoredLayerStyles, validateStyleConfig, } from "./styleEditorUtils"; @@ -104,4 +106,25 @@ describe("styleEditorUtils", () => { }), ).toBe(true); }); + + it("hydrates workspace settings as inactive drafts", () => { + const storedPipeStyle = { + ...createDefaultLayerStyleState("pipes").styleConfig, + property: "flow", + showLabels: false, + }; + const restored = hydrateStoredLayerStyleStates( + { + version: 2, + layers: { pipes: storedPipeStyle }, + }, + 2, + ); + + expect(restored.find((state) => state.layerId === "pipes")?.styleConfig).toEqual( + storedPipeStyle, + ); + expect(restored.every((state) => !state.isActive)).toBe(true); + expect(selectStoredLayerStyles(restored).pipes).toEqual(storedPipeStyle); + }); }); diff --git a/src/components/olmap/core/Controls/styleEditorUtils.ts b/src/components/olmap/core/Controls/styleEditorUtils.ts index 6f59b49..4550aa8 100644 --- a/src/components/olmap/core/Controls/styleEditorUtils.ts +++ b/src/components/olmap/core/Controls/styleEditorUtils.ts @@ -4,11 +4,14 @@ import { calculateClassification } from "@utils/breaksClassification"; import { parseColor } from "@utils/parseColor"; import { + createDefaultLayerStyleStates, GRADIENT_PALETTES, RAINBOW_PALETTES, SINGLE_COLOR_PALETTES, } from "./styleEditorPresets"; import type { + DefaultLayerStyleId, + LayerStyleState, ResolvedLayerStyle, StyleConfig, StyleValidationResult, @@ -232,6 +235,44 @@ export const validateStyleConfig = (styleConfig: StyleConfig): StyleValidationRe return { valid: errors.length === 0, errors }; }; +export const hydrateStoredLayerStyleStates = ( + document: unknown, + expectedVersion: number, +): LayerStyleState[] => { + const defaults = createDefaultLayerStyleStates(); + if (!document || typeof document !== "object") return defaults; + + const storedDocument = document as { + version?: number; + layers?: Partial<Record<DefaultLayerStyleId, StyleConfig>>; + }; + if (storedDocument.version !== expectedVersion || !storedDocument.layers) { + return defaults; + } + + return defaults.map((state) => { + const stored = storedDocument.layers?.[state.layerId as DefaultLayerStyleId]; + if (!stored || !validateStyleConfig(stored).valid) return state; + return { + ...state, + styleConfig: { + ...stored, + customBreaks: [...(stored.customBreaks || [])], + customColors: [...(stored.customColors || [])], + }, + legendConfig: { ...state.legendConfig, property: stored.property }, + isActive: false, + }; + }); +}; + +export const selectStoredLayerStyles = (states: LayerStyleState[]) => + Object.fromEntries( + states + .filter((state) => state.layerId === "junctions" || state.layerId === "pipes") + .map((state) => [state.layerId, state.styleConfig]), + ) as Partial<Record<DefaultLayerStyleId, StyleConfig>>; + export const requiresStyleApply = ( applied: StyleConfig | undefined, draft: StyleConfig, diff --git a/src/components/olmap/core/Controls/useStyleEditor.ts b/src/components/olmap/core/Controls/useStyleEditor.ts index c708ce3..d168c7e 100644 --- a/src/components/olmap/core/Controls/useStyleEditor.ts +++ b/src/components/olmap/core/Controls/useStyleEditor.ts @@ -20,11 +20,13 @@ import { buildStyleVariables, getDefaultCustomBreaks, getDefaultCustomColors, + hydrateStoredLayerStyleStates, normalizeCustomBreaks, requiresStyleApply, resolveDimensions, resolveLayerStyle, resolveStyleColors, + selectStoredLayerStyles, validateStyleConfig, } from "./styleEditorUtils"; import type { @@ -885,23 +887,10 @@ export const useStyleEditor = ({ try { const raw = window.localStorage.getItem(storageKey); if (raw) { - const document = JSON.parse(raw) as { - version?: number; - layers?: Partial<Record<DefaultLayerStyleId, StyleConfig>>; - }; - if (document.version === STYLE_STORAGE_VERSION && document.layers) { - restored = createDefaultLayerStyleStates().map((state) => { - if (!isDefaultLayerId(state.layerId)) return state; - const stored = document.layers?.[state.layerId]; - if (!stored || !validateStyleConfig(stored).valid) return state; - return { - ...state, - styleConfig: cloneStyleConfig(stored), - legendConfig: { ...state.legendConfig, property: stored.property }, - isActive: true, - }; - }); - } + restored = hydrateStoredLayerStyleStates( + JSON.parse(raw), + STYLE_STORAGE_VERSION, + ); } } catch (error) { console.warn("Restore layer styles failed", error); @@ -910,19 +899,14 @@ export const useStyleEditor = ({ setLayerStyleStates(restored); const restoredSelection = restored.find((state) => state.layerId === "junctions"); if (restoredSelection) setStyleConfig(cloneStyleConfig(restoredSelection.styleConfig)); - restored - .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) - .forEach((state) => syncAuxiliaryLayers(state.layerId as DefaultLayerStyleId, state.styleConfig)); + syncAuxiliaryLayers("junctions", null); + syncAuxiliaryLayers("pipes", null); setPersistenceReady(true); }, [setLayerStyleStates, storageKey, syncAuxiliaryLayers]); useEffect(() => { if (!persistenceReady) return; - const layers = Object.fromEntries( - layerStyleStates - .filter((state) => state.isActive && isDefaultLayerId(state.layerId)) - .map((state) => [state.layerId, state.styleConfig]), - ); + const layers = selectStoredLayerStyles(layerStyleStates); try { window.localStorage.setItem( storageKey, -- 2.54.0 From a52c04204d84508d92a87f7cf087243a5a76e9ad Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 16:22:16 +0800 Subject: [PATCH 235/281] refactor(admin): simplify system management UI --- src/components/admin/SystemAdminPanel.tsx | 691 ++++++++-------------- 1 file changed, 262 insertions(+), 429 deletions(-) diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index 246528c..b1f1ad2 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -1,6 +1,13 @@ "use client"; -import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import React, { + FormEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Alert, alpha, @@ -47,6 +54,7 @@ import { Security as SecurityIcon, Storage as StorageIcon, } from "@mui/icons-material"; +import { useNotification } from "@refinedev/core"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; import { useProjectStore } from "@/store/projectStore"; @@ -125,7 +133,7 @@ const projectStatusOptions = [ const databaseRoleOptions = [ { value: "biz_data", label: "业务数据库", helper: "PostgreSQL / 管网业务数据" }, { value: "iot_data", label: "时序数据库", helper: "TimescaleDB / SCADA 与实时数据" }, -]; +] as const; const defaultProjectForm = { name: "", @@ -149,17 +157,32 @@ const defaultDatabaseForms = { }, }; +type ProjectFormState = typeof defaultProjectForm; +type DatabaseRole = keyof typeof defaultDatabaseForms; +type DatabaseFormState = (typeof defaultDatabaseForms)[DatabaseRole]; + const createDefaultDatabaseForms = () => ({ biz_data: { ...defaultDatabaseForms.biz_data }, iot_data: { ...defaultDatabaseForms.iot_data }, }); +const createEmptyDatabaseHealth = (): Record< + DatabaseRole, + DatabaseHealth | null +> => ({ + biz_data: null, + iot_data: null, +}); + const getBusinessRoleLabel = (role: string) => businessRoleOptions.find((option) => option.value === role)?.label ?? role; const getProjectRoleLabel = (role: string) => projectRoleLabels[role] ?? role; +const getDatabaseRoleLabel = (role: DatabaseRole) => + databaseRoleOptions.find((option) => option.value === role)?.label ?? role; + const formatJsonField = (value?: Record<string, unknown> | null) => value ? JSON.stringify(value, null, 2) : ""; @@ -254,8 +277,8 @@ const StatCard = ({ icon, label, value, tone = "primary" }: StatCardProps) => ( sx={(theme) => ({ ...cardSx, p: 2, - flex: "1 1 180px", minWidth: 0, + boxShadow: "none", bgcolor: alpha(theme.palette[tone].main, 0.04), })} > @@ -274,7 +297,7 @@ const StatCard = ({ icon, label, value, tone = "primary" }: StatCardProps) => ( {icon} </Box> <Box sx={{ minWidth: 0 }}> - <Typography variant="body2" color="text.secondary" noWrap> + <Typography variant="body2" color="text.secondary" lineHeight={1.2}> {label} </Typography> <Typography variant="h6" fontWeight={800} lineHeight={1.2}> @@ -335,7 +358,100 @@ const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => ( </TableRow> ); +type ProjectFormFieldsProps = { + value: ProjectFormState; + onChange: (value: ProjectFormState) => void; + disabled?: boolean; +}; + +const ProjectFormFields = ({ + value, + onChange, + disabled = false, +}: ProjectFormFieldsProps) => { + const updateField = (field: keyof ProjectFormState, nextValue: string) => { + onChange({ ...value, [field]: nextValue }); + }; + + return ( + <Stack spacing={2}> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + label="项目名称" + value={value.name} + onChange={(event) => updateField("name", event.target.value)} + disabled={disabled} + required + fullWidth + /> + <TextField + size="small" + label="项目代码" + value={value.code} + onChange={(event) => updateField("code", event.target.value)} + disabled={disabled} + required + fullWidth + /> + </Stack> + <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> + <TextField + size="small" + label="GeoServer 工作区" + value={value.gs_workspace} + onChange={(event) => updateField("gs_workspace", event.target.value)} + disabled={disabled} + required + fullWidth + /> + <FormControl + size="small" + sx={{ minWidth: { xs: "100%", md: 160 } }} + disabled={disabled} + > + <InputLabel>状态</InputLabel> + <Select + label="状态" + value={value.status} + onChange={(event) => updateField("status", event.target.value)} + > + {projectStatusOptions.map((option) => ( + <MenuItem key={option.value} value={option.value}> + {option.label} + </MenuItem> + ))} + </Select> + </FormControl> + </Stack> + <TextField + size="small" + label="描述" + value={value.description} + onChange={(event) => updateField("description", event.target.value)} + disabled={disabled} + fullWidth + multiline + minRows={2} + /> + <TextField + size="small" + label="地图范围 JSON" + value={value.map_extent} + onChange={(event) => updateField("map_extent", event.target.value)} + disabled={disabled} + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + </Stack> + ); +}; + export const SystemAdminPanel = () => { + const { open: openNotification } = useNotification(); + const openNotificationRef = useRef(openNotification); const currentProjectId = useProjectStore((state) => state.currentProjectId); const [tab, setTab] = useState(0); const [users, setUsers] = useState<MetadataUser[]>([]); @@ -347,10 +463,6 @@ export const SystemAdminPanel = () => { const [isAuthorized, setIsAuthorized] = useState(false); const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true); const [projectId, setProjectId] = useState(currentProjectId ?? ""); - const [hasLoadedCurrentProjectMembers, setHasLoadedCurrentProjectMembers] = - useState(false); - const [message, setMessage] = useState<string | null>(null); - const [error, setError] = useState<string | null>(null); const [memberForm, setMemberForm] = useState({ user_id: "", project_role: "viewer", @@ -359,23 +471,36 @@ export const SystemAdminPanel = () => { const [createProjectOpen, setCreateProjectOpen] = useState(false); const [createProjectForm, setCreateProjectForm] = useState(defaultProjectForm); const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms); - const [databaseHealth, setDatabaseHealth] = useState< - Record<string, DatabaseHealth | null> - >({ - biz_data: null, - iot_data: null, - }); + const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth); - const activeUsers = useMemo( - () => users.filter((user) => user.is_active).length, - [users], + useEffect(() => { + openNotificationRef.current = openNotification; + }, [openNotification]); + + const notifySuccess = useCallback( + (message: string) => { + openNotificationRef.current?.({ type: "success", message }); + }, + [], ); - const systemAdminUsers = useMemo( - () => users.filter((user) => user.role === "admin").length, - [users], + + const notifyError = useCallback( + (message: string) => { + openNotificationRef.current?.({ type: "error", message }); + }, + [], ); - const superUsers = useMemo( - () => users.filter((user) => user.is_superuser).length, + + const userStats = useMemo( + () => + users.reduce( + (stats, user) => ({ + active: stats.active + Number(user.is_active), + systemAdmins: stats.systemAdmins + Number(user.role === "admin"), + superusers: stats.superusers + Number(user.is_superuser), + }), + { active: 0, systemAdmins: 0, superusers: 0 }, + ), [users], ); const memberUserIds = useMemo( @@ -440,7 +565,6 @@ export const SystemAdminPanel = () => { if (isFastApiRouteNotFound(response, errorText)) { setMetadataConfigAvailable(false); setProjects([]); - applyProjectForm(null); return; } throw new Error(errorText); @@ -457,10 +581,7 @@ export const SystemAdminPanel = () => { if (activeProjectId && !projectId.trim()) { setProjectId(activeProjectId); } - applyProjectForm( - payload.find((project) => project.project_id === activeProjectId) ?? null, - ); - }, [applyProjectForm, currentProjectId, projectId]); + }, [currentProjectId, projectId]); const loadMembers = useCallback(async () => { if (!projectId.trim()) return; @@ -475,7 +596,7 @@ export const SystemAdminPanel = () => { const loadDatabases = useCallback(async () => { if (!projectId.trim()) return; - setDatabaseHealth({ biz_data: null, iot_data: null }); + setDatabaseHealth(createEmptyDatabaseHealth()); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases`, ); @@ -546,7 +667,6 @@ export const SystemAdminPanel = () => { if (!cancelled) { setMetadataConfigAvailable(false); setProjects([]); - applyProjectForm(null); } return; } @@ -561,16 +681,11 @@ export const SystemAdminPanel = () => { setProjectId((current) => current.trim() || !initialProjectId ? current : initialProjectId, ); - applyProjectForm( - projectsPayload.find( - (project) => project.project_id === initialProjectId, - ) ?? null, - ); } } catch (err) { if (!cancelled) { setAdminChecked(true); - setError(String(err)); + notifyError(String(err)); } } }; @@ -580,7 +695,7 @@ export const SystemAdminPanel = () => { return () => { cancelled = true; }; - }, [applyProjectForm, currentProjectId]); + }, [currentProjectId, notifyError]); useEffect(() => { if (!currentProjectId || projectId.trim()) return; @@ -588,26 +703,23 @@ export const SystemAdminPanel = () => { }, [currentProjectId, projectId]); useEffect(() => { - if (!isAuthorized || !hasProjectId || hasLoadedCurrentProjectMembers) return; - - setHasLoadedCurrentProjectMembers(true); - loadMembers().catch((err) => setError(String(err))); - }, [hasLoadedCurrentProjectMembers, hasProjectId, isAuthorized, loadMembers]); + if (!isAuthorized || !hasProjectId) return; + loadMembers().catch((err) => notifyError(String(err))); + }, [hasProjectId, isAuthorized, loadMembers, notifyError]); useEffect(() => { - if (!selectedProject) return; - applyProjectForm(selectedProject); + applyProjectForm(selectedProject ?? null); }, [applyProjectForm, selectedProject]); useEffect(() => { if (!isAuthorized || !hasProjectId || !metadataConfigAvailable) return; - setDatabaseHealth({ biz_data: null, iot_data: null }); - loadDatabases().catch((err) => setError(String(err))); + loadDatabases().catch((err) => notifyError(String(err))); }, [ hasProjectId, isAuthorized, loadDatabases, metadataConfigAvailable, + notifyError, ]); useEffect(() => { @@ -617,7 +729,6 @@ export const SystemAdminPanel = () => { }, [metadataConfigAvailable, tab]); const updateUserActive = async (user: MetadataUser, isActive: boolean) => { - setError(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/users/${user.id}`, { @@ -627,14 +738,13 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); return; } await loadUsers(); }; const updateUserRole = async (user: MetadataUser, role: string) => { - setError(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/users/${user.id}`, { @@ -644,7 +754,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); return; } await loadUsers(); @@ -652,8 +762,6 @@ export const SystemAdminPanel = () => { const addMember = async (event: FormEvent) => { event.preventDefault(); - setError(null); - setMessage(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/members`, { @@ -663,16 +771,15 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); return; } - setMessage("项目成员已添加"); + notifySuccess("项目成员已添加"); setMemberForm((prev) => ({ ...prev, user_id: "" })); await loadMembers(); }; const updateMemberRole = async (member: ProjectMember, projectRole: string) => { - setError(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${member.project_id}/members/${member.user_id}`, { @@ -682,23 +789,22 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); return; } await loadMembers(); }; const removeMember = async (member: ProjectMember) => { - setError(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${member.project_id}/members/${member.user_id}`, { method: "DELETE" }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); return; } - setMessage("项目成员已移除"); + notifySuccess("项目成员已移除"); await loadMembers(); }; @@ -709,18 +815,25 @@ export const SystemAdminPanel = () => { const selectProject = (value: string) => { setProjectId(value); - setHasLoadedCurrentProjectMembers(false); setMembers([]); setDatabases([]); setDatabaseForms(createDefaultDatabaseForms()); - setDatabaseHealth({ biz_data: null, iot_data: null }); + setDatabaseHealth(createEmptyDatabaseHealth()); }; - const resetDatabaseHealth = (role: keyof typeof defaultDatabaseForms) => { + const updateDatabaseForm = <Field extends keyof DatabaseFormState>( + role: DatabaseRole, + field: Field, + value: DatabaseFormState[Field], + ) => { setDatabaseHealth((current) => ({ ...current, [role]: null })); + setDatabaseForms((current) => ({ + ...current, + [role]: { ...current[role], [field]: value }, + })); }; - const buildProjectPayload = (form: typeof defaultProjectForm) => ({ + const buildProjectPayload = (form: ProjectFormState) => ({ name: form.name.trim(), code: form.code.trim(), description: form.description.trim() || null, @@ -732,11 +845,9 @@ export const SystemAdminPanel = () => { const saveProject = async (event: FormEvent) => { event.preventDefault(); if (!selectedProject?.project_id) { - setError("请先选择要编辑的项目。"); + notifyError("请先选择要编辑的项目。"); return; } - setError(null); - setMessage(null); try { const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${selectedProject.project_id}`, @@ -750,20 +861,16 @@ export const SystemAdminPanel = () => { throw new Error(await readErrorText(response)); } const saved = (await response.json()) as AdminProject; - setHasLoadedCurrentProjectMembers(false); setProjectId(saved.project_id); - applyProjectForm(saved); await loadProjects(saved.project_id); - setMessage("项目配置已更新"); + notifySuccess("项目配置已更新"); } catch (err) { - setError(String(err)); + notifyError(String(err)); } }; const createProject = async (event: FormEvent) => { event.preventDefault(); - setError(null); - setMessage(null); try { const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`, { method: "POST", @@ -776,29 +883,25 @@ export const SystemAdminPanel = () => { const saved = (await response.json()) as AdminProject; setCreateProjectOpen(false); setCreateProjectForm(defaultProjectForm); - setHasLoadedCurrentProjectMembers(false); setProjectId(saved.project_id); - applyProjectForm(saved); await loadProjects(saved.project_id); - setMessage("项目已创建"); + notifySuccess("项目已创建"); } catch (err) { - setError(String(err)); + notifyError(String(err)); } }; - const saveDatabase = async (role: keyof typeof defaultDatabaseForms) => { + const saveDatabase = async (role: DatabaseRole) => { if (!projectId.trim()) return; const form = databaseForms[role]; if (!form.dsn.trim()) { - setError("请填写新的 DSN 并通过连通性测试后再保存。"); + notifyError("请填写新的 DSN 并通过连通性测试后再保存。"); return; } if (!databaseHealth[role]?.ok) { - setError("请先通过连通性测试再保存数据库配置。"); + notifyError("请先通过连通性测试再保存数据库配置。"); return; } - setError(null); - setMessage(null); const payload: Record<string, unknown> = { db_role: role, pool_min_size: Number(form.pool_min_size), @@ -821,20 +924,19 @@ export const SystemAdminPanel = () => { setMetadataConfigAvailable(false); return; } - setError(errorText); + notifyError(errorText); return; } setDatabaseForms((current) => ({ ...current, [role]: { ...current[role], dsn: "" }, })); - setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}已保存`); + notifySuccess(`${getDatabaseRoleLabel(role)}已保存`); await loadDatabases(); }; - const checkDatabaseHealth = async (role: keyof typeof defaultDatabaseForms) => { + const checkDatabaseHealth = async (role: DatabaseRole) => { if (!projectId.trim()) return; - setError(null); setDatabaseHealth((current) => ({ ...current, [role]: null })); const form = databaseForms[role]; const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {}; @@ -866,7 +968,7 @@ export const SystemAdminPanel = () => { })); return; } - setError(normalizeDatabaseHealthDetail(errorText, false)); + notifyError(normalizeDatabaseHealthDetail(errorText, false)); return; } const payload = (await response.json()) as DatabaseHealth; @@ -879,9 +981,8 @@ export const SystemAdminPanel = () => { })); }; - const deleteDatabase = async (role: keyof typeof defaultDatabaseForms) => { + const deleteDatabase = async (role: DatabaseRole) => { if (!projectId.trim()) return; - setError(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}`, { method: "DELETE" }, @@ -892,10 +993,10 @@ export const SystemAdminPanel = () => { setMetadataConfigAvailable(false); return; } - setError(errorText); + notifyError(errorText); return; } - setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}配置已删除`); + notifySuccess(`${getDatabaseRoleLabel(role)}配置已删除`); await loadDatabases(); }; @@ -920,43 +1021,29 @@ export const SystemAdminPanel = () => { : alpha(theme.palette.primary.main, 0.04), })} > - <Stack - direction={{ xs: "column", md: "row" }} - spacing={2} - alignItems={{ xs: "stretch", md: "center" }} - justifyContent="space-between" - > - <Stack direction="row" spacing={1.5} alignItems="center"> - <Box - sx={(theme) => ({ - width: 48, - height: 48, - borderRadius: 2, - display: "grid", - placeItems: "center", - color: "primary.main", - bgcolor: alpha(theme.palette.primary.main, 0.12), - })} - > - <SecurityIcon /> - </Box> - <Box> - <Typography variant="h5" fontWeight={800}> - 系统管理 - </Typography> - <Typography variant="body2" color="text.secondary"> - Keycloak 负责登录身份,系统配置库负责系统角色、账号状态和项目权限 - </Typography> - </Box> - </Stack> - {adminChecked && isAuthorized && ( - <Chip - color="success" - icon={<AdminPanelSettingsIcon />} - label="管理员权限已验证" - sx={{ alignSelf: { xs: "flex-start", md: "center" } }} - /> - )} + <Stack direction="row" spacing={1.5} alignItems="center"> + <Box + sx={(theme) => ({ + width: 48, + height: 48, + borderRadius: 2, + display: "grid", + placeItems: "center", + color: "primary.main", + bgcolor: alpha(theme.palette.primary.main, 0.12), + flexShrink: 0, + })} + > + <SecurityIcon /> + </Box> + <Box> + <Typography variant="h5" fontWeight={800}> + 系统管理 + </Typography> + <Typography variant="body2" color="text.secondary"> + 管理系统用户、项目权限和数据库连接 + </Typography> + </Box> </Stack> </Paper> @@ -974,16 +1061,6 @@ export const SystemAdminPanel = () => { 无系统管理权限 </Alert> )} - {message && ( - <Alert severity="success" onClose={() => setMessage(null)} sx={{ borderRadius: 2 }}> - {message} - </Alert> - )} - {error && ( - <Alert severity="error" onClose={() => setError(null)} sx={{ borderRadius: 2 }}> - {error} - </Alert> - )} {isAuthorized && !metadataConfigAvailable && ( <Alert severity="warning" sx={{ borderRadius: 2 }}> 后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置和数据库配置。 @@ -992,7 +1069,17 @@ export const SystemAdminPanel = () => { {isAuthorized && ( <> - <Stack direction={{ xs: "column", md: "row" }} spacing={2}> + <Box + sx={{ + display: "grid", + gridTemplateColumns: { + xs: "repeat(2, minmax(0, 1fr))", + md: "repeat(3, minmax(0, 1fr))", + xl: "repeat(6, minmax(0, 1fr))", + }, + gap: 2, + }} + > <StatCard icon={<PeopleIcon />} label="系统用户" @@ -1001,24 +1088,24 @@ export const SystemAdminPanel = () => { <StatCard icon={<CheckCircleIcon />} label="启用用户" - value={activeUsers} + value={userStats.active} tone="success" /> <StatCard icon={<AdminPanelSettingsIcon />} - label="系统管理员角色" - value={systemAdminUsers} + label="系统管理员" + value={userStats.systemAdmins} tone="warning" /> <StatCard icon={<SecurityIcon />} label="超级管理员" - value={superUsers} + value={userStats.superusers} tone="info" /> <StatCard icon={<GroupsIcon />} - label={hasProjectId ? "当前项目成员" : "项目成员未加载"} + label="当前项目成员" value={hasProjectId ? members.length : "-"} tone="info" /> @@ -1028,7 +1115,7 @@ export const SystemAdminPanel = () => { value={projects.length} tone="primary" /> - </Stack> + </Box> {metadataConfigAvailable && ( <Paper variant="outlined" sx={{ ...cardSx, p: 2 }}> @@ -1270,63 +1357,6 @@ export const SystemAdminPanel = () => { 当前未选择管理项目。 </Alert> )} - {hasProjectId && ( - <Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}> - <Stack - direction={{ xs: "column", md: "row" }} - spacing={2} - alignItems={{ xs: "stretch", md: "center" }} - justifyContent="space-between" - > - <Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0 }}> - <Box - sx={(theme) => ({ - width: 40, - height: 40, - borderRadius: 1.5, - display: "grid", - placeItems: "center", - color: "primary.main", - bgcolor: alpha(theme.palette.primary.main, 0.12), - flexShrink: 0, - })} - > - <DnsIcon /> - </Box> - <Box sx={{ minWidth: 0 }}> - <Typography variant="caption" color="text.secondary"> - 当前管理项目 - </Typography> - <Typography variant="subtitle1" fontWeight={800} noWrap> - {selectedProject?.name ?? "未命名项目"} - </Typography> - <Typography variant="body2" color="text.secondary" noWrap> - {selectedProject - ? `${selectedProject.code} · ${projectId}` - : projectId} - </Typography> - </Box> - </Stack> - <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> - {selectedProject && ( - <Chip - color={selectedProject.status === "active" ? "success" : "default"} - label={ - projectStatusOptions.find( - (option) => option.value === selectedProject.status, - )?.label ?? selectedProject.status - } - /> - )} - <Chip - color="info" - icon={<GroupsIcon />} - label={`${members.length} 名成员`} - /> - </Stack> - </Stack> - </Paper> - )} <Paper component="form" variant="outlined" @@ -1534,11 +1564,13 @@ export const SystemAdminPanel = () => { title="项目配置" description="维护项目基础信息、GeoServer 工作区、地图范围和项目状态。" action={ - <Stack direction="row" spacing={1}> - <Button startIcon={<RefreshIcon />} variant="outlined" onClick={() => loadProjects()}> - 刷新 - </Button> - </Stack> + <Button + startIcon={<RefreshIcon />} + variant="outlined" + onClick={() => loadProjects()} + > + 刷新 + </Button> } /> {!selectedProject && ( @@ -1553,93 +1585,10 @@ export const SystemAdminPanel = () => { onSubmit={saveProject} > <Stack spacing={2}> - <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> - <TextField - size="small" - label="项目名称" - value={projectForm.name} - onChange={(event) => - setProjectForm({ ...projectForm, name: event.target.value }) - } - disabled={!selectedProject} - required - fullWidth - /> - <TextField - size="small" - label="项目代码" - value={projectForm.code} - onChange={(event) => - setProjectForm({ ...projectForm, code: event.target.value }) - } - disabled={!selectedProject} - required - fullWidth - /> - <TextField - size="small" - label="GeoServer 工作区" - value={projectForm.gs_workspace} - onChange={(event) => - setProjectForm({ - ...projectForm, - gs_workspace: event.target.value, - }) - } - disabled={!selectedProject} - required - fullWidth - /> - <FormControl size="small" sx={{ minWidth: 140 }} disabled={!selectedProject}> - <InputLabel>状态</InputLabel> - <Select - label="状态" - value={projectForm.status} - onChange={(event) => - setProjectForm({ - ...projectForm, - status: event.target.value, - }) - } - > - {projectStatusOptions.map((option) => ( - <MenuItem key={option.value} value={option.value}> - {option.label} - </MenuItem> - ))} - </Select> - </FormControl> - </Stack> - <TextField - size="small" - label="描述" - value={projectForm.description} - onChange={(event) => - setProjectForm({ - ...projectForm, - description: event.target.value, - }) - } + <ProjectFormFields + value={projectForm} + onChange={setProjectForm} disabled={!selectedProject} - fullWidth - multiline - minRows={2} - /> - <TextField - size="small" - label="地图范围 JSON" - value={projectForm.map_extent} - onChange={(event) => - setProjectForm({ - ...projectForm, - map_extent: event.target.value, - }) - } - disabled={!selectedProject} - fullWidth - multiline - minRows={4} - placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' /> <Stack direction="row" justifyContent="flex-end"> <Button @@ -1679,7 +1628,7 @@ export const SystemAdminPanel = () => { )} {hasProjectId && databaseRoleOptions.map((roleOption) => { - const role = roleOption.value as keyof typeof defaultDatabaseForms; + const role = roleOption.value; const form = databaseForms[role]; const configRecord = databasesByRole.get(role); const health = databaseHealth[role]; @@ -1713,13 +1662,9 @@ export const SystemAdminPanel = () => { type="password" label={configRecord?.has_dsn ? "替换 DSN" : "DSN"} value={form.dsn} - onChange={(event) => { - resetDatabaseHealth(role); - setDatabaseForms((current) => ({ - ...current, - [role]: { ...current[role], dsn: event.target.value }, - })); - }} + onChange={(event) => + updateDatabaseForm(role, "dsn", event.target.value) + } placeholder="postgresql://user:password@host:5432/db" helperText={ configRecord?.has_dsn @@ -1733,16 +1678,13 @@ export const SystemAdminPanel = () => { type="number" label="最小连接" value={form.pool_min_size} - onChange={(event) => { - resetDatabaseHealth(role); - setDatabaseForms((current) => ({ - ...current, - [role]: { - ...current[role], - pool_min_size: Number(event.target.value), - }, - })); - }} + onChange={(event) => + updateDatabaseForm( + role, + "pool_min_size", + Number(event.target.value), + ) + } sx={{ width: { xs: "100%", md: 120 } }} /> <TextField @@ -1750,16 +1692,13 @@ export const SystemAdminPanel = () => { type="number" label="最大连接" value={form.pool_max_size} - onChange={(event) => { - resetDatabaseHealth(role); - setDatabaseForms((current) => ({ - ...current, - [role]: { - ...current[role], - pool_max_size: Number(event.target.value), - }, - })); - }} + onChange={(event) => + updateDatabaseForm( + role, + "pool_max_size", + Number(event.target.value), + ) + } sx={{ width: { xs: "100%", md: 120 } }} /> </Stack> @@ -1854,116 +1793,10 @@ export const SystemAdminPanel = () => { </Stack> </DialogTitle> <DialogContent dividers sx={{ px: 3, py: 2.5 }}> - <Stack spacing={2.5}> - <Box> - <Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}> - 基础信息 - </Typography> - <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> - <TextField - size="small" - label="项目名称" - value={createProjectForm.name} - onChange={(event) => - setCreateProjectForm({ - ...createProjectForm, - name: event.target.value, - }) - } - required - fullWidth - /> - <TextField - size="small" - label="项目代码" - value={createProjectForm.code} - onChange={(event) => - setCreateProjectForm({ - ...createProjectForm, - code: event.target.value, - }) - } - required - fullWidth - /> - </Stack> - </Box> - <Box> - <Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}> - 服务配置 - </Typography> - <Stack direction={{ xs: "column", md: "row" }} spacing={1.5}> - <TextField - size="small" - label="GeoServer 工作区" - value={createProjectForm.gs_workspace} - onChange={(event) => - setCreateProjectForm({ - ...createProjectForm, - gs_workspace: event.target.value, - }) - } - required - fullWidth - /> - <FormControl size="small" sx={{ minWidth: { xs: "100%", md: 160 } }}> - <InputLabel>状态</InputLabel> - <Select - label="状态" - value={createProjectForm.status} - onChange={(event) => - setCreateProjectForm({ - ...createProjectForm, - status: event.target.value, - }) - } - > - {projectStatusOptions.map((option) => ( - <MenuItem key={option.value} value={option.value}> - {option.label} - </MenuItem> - ))} - </Select> - </FormControl> - </Stack> - </Box> - <Box> - <Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}> - 描述与范围 - </Typography> - <Stack spacing={1.5}> - <TextField - size="small" - label="描述" - value={createProjectForm.description} - onChange={(event) => - setCreateProjectForm({ - ...createProjectForm, - description: event.target.value, - }) - } - fullWidth - multiline - minRows={2} - /> - <TextField - size="small" - label="地图范围 JSON" - value={createProjectForm.map_extent} - onChange={(event) => - setCreateProjectForm({ - ...createProjectForm, - map_extent: event.target.value, - }) - } - fullWidth - multiline - minRows={4} - placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' - /> - </Stack> - </Box> - </Stack> + <ProjectFormFields + value={createProjectForm} + onChange={setCreateProjectForm} + /> </DialogContent> <DialogActions sx={{ px: 3, py: 2, bgcolor: "action.hover" }}> <Button onClick={() => setCreateProjectOpen(false)}>取消</Button> -- 2.54.0 From 7af90e495d9547f1eecead9ac34ba5f7a8f91921 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 16:24:04 +0800 Subject: [PATCH 236/281] feat(burst-detection): update analysis parameters --- .../BurstDetection/AnalysisParameters.test.ts | 57 ++ .../BurstDetection/AnalysisParameters.tsx | 587 ++++++----------- .../olmap/BurstDetection/DetectionResults.tsx | 596 ++++++------------ .../olmap/BurstDetection/SchemeQuery.tsx | 45 +- src/components/olmap/BurstDetection/types.ts | 30 + 5 files changed, 521 insertions(+), 794 deletions(-) create mode 100644 src/components/olmap/BurstDetection/AnalysisParameters.test.ts diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.test.ts b/src/components/olmap/BurstDetection/AnalysisParameters.test.ts new file mode 100644 index 0000000..46be13f --- /dev/null +++ b/src/components/olmap/BurstDetection/AnalysisParameters.test.ts @@ -0,0 +1,57 @@ +import dayjs from "dayjs"; +import { + buildBurstDetectionRequest, + createBurstDetectionAnalysisParametersState, + parseScadaFrequencyMinutes, + resolvePressureSamplingInterval, +} from "./AnalysisParameters"; + +describe("burst detection request", () => { + it("requests the latest complete monitoring time by default", () => { + const state = createBurstDetectionAnalysisParametersState(); + state.schemeName = " latest-case "; + + expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({ + network: "fengyang", + scheme_name: "latest-case", + sampling_interval_minutes: 15, + }); + expect(state.detectionMode).toBe("latest"); + expect(state.targetTime?.minute() % 15).toBe(0); + }); + + it("sends one target time for historical replay", () => { + const targetTime = dayjs("2026-06-20T13:30:00+08:00"); + + expect( + buildBurstDetectionRequest( + { + schemeName: "history-case", + detectionMode: "historical", + targetTime, + samplingIntervalMinutes: 30, + samplingIntervalSource: "manual", + }, + "fengyang", + ), + ).toEqual({ + network: "fengyang", + scheme_name: "history-case", + sampling_interval_minutes: 30, + target_time: targetTime.toISOString(), + }); + }); + + it("uses the dominant pressure SCADA frequency as the editable default", () => { + expect(parseScadaFrequencyMinutes("0:15:00")).toBe(15); + expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60); + expect( + resolvePressureSamplingInterval([ + { type: "pressure", transmission_frequency: "0:15:00" }, + { type: "pressure", transmission_frequency: "0:15:00" }, + { type: "pressure", transmission_frequency: "0:30:00" }, + { type: "pipe_flow", transmission_frequency: "1:00:00" }, + ]), + ).toBe(15); + }); +}); diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index 4a11577..9ff1dc5 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -1,19 +1,14 @@ "use client"; -import React, { useMemo, useState, useCallback } from "react"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import RefreshIcon from "@mui/icons-material/Refresh"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, - CircularProgress, - Collapse, FormControl, MenuItem, Select, TextField, Typography, - IconButton, } from "@mui/material"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; @@ -23,7 +18,7 @@ import { useNotification } from "@refinedev/core"; import dayjs, { Dayjs } from "dayjs"; import "dayjs/locale/zh-cn"; import { api } from "@/lib/api"; -import { NETWORK_NAME, config } from "@config/config"; +import { NETWORK_NAME } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { BurstDetectionResult } from "./types"; @@ -33,186 +28,149 @@ interface Props { onStateChange?: (state: BurstDetectionAnalysisParametersState) => void; } -export interface SchemeItem { - scheme_id: number; - scheme_name: string; - scheme_type: string; - create_time: string; - scheme_start_time: string; - scheme_detail?: { - modify_total_duration: number; - }; -} - export interface BurstDetectionAnalysisParametersState { schemeName: string; - dataSource: "monitoring" | "simulation"; - schemes: SchemeItem[]; - selectedSchemeId: number | ""; - scadaStart: Dayjs | null; - scadaEnd: Dayjs | null; - mu: number; - pointsPerDay: number; - nEstimators: number; - contaminationInput: string; - advancedOpen: boolean; + detectionMode: "latest" | "historical"; + targetTime: Dayjs | null; + samplingIntervalMinutes: number; + samplingIntervalSource: "metadata" | "manual"; } +interface ScadaInfoItem { + type?: string; + transmission_frequency?: string | number | null; +} + +const currentQuarterHour = () => { + const now = dayjs().second(0).millisecond(0); + return now.minute(Math.floor(now.minute() / 15) * 15); +}; + export const createBurstDetectionAnalysisParametersState = (): BurstDetectionAnalysisParametersState => ({ schemeName: `Burst_Detection_${Date.now()}`, - dataSource: "monitoring", - schemes: [], - selectedSchemeId: "", - scadaStart: dayjs().subtract(3, "day"), - scadaEnd: dayjs(), - mu: 100, - pointsPerDay: 96, - nEstimators: 50, - contaminationInput: "auto", - advancedOpen: false, + detectionMode: "latest", + targetTime: currentQuarterHour(), + samplingIntervalMinutes: 15, + samplingIntervalSource: "metadata", }); +export const parseScadaFrequencyMinutes = ( + value: string | number | null | undefined, +): number | null => { + if (typeof value === "number") { + return Number.isInteger(value) && value > 0 ? value : null; + } + if (!value) return null; + const normalized = value.trim(); + const dayMatch = normalized.match(/^(\d+)\s+days?,\s*(.+)$/i); + const days = dayMatch ? Number(dayMatch[1]) : 0; + const timePart = dayMatch ? dayMatch[2] : normalized; + const parts = timePart.split(":").map(Number); + if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) { + return null; + } + const minutes = days * 1440 + parts[0] * 60 + parts[1] + parts[2] / 60; + return Number.isInteger(minutes) && minutes > 0 ? minutes : null; +}; + +export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => { + const counts = new Map<number, number>(); + items + .filter((item) => item.type?.toLowerCase() === "pressure") + .forEach((item) => { + const minutes = parseScadaFrequencyMinutes(item.transmission_frequency); + if (minutes && 1440 % minutes === 0) { + counts.set(minutes, (counts.get(minutes) ?? 0) + 1); + } + }); + return [...counts.entries()].sort( + ([minutesA, countA], [minutesB, countB]) => + countB - countA || minutesA - minutesB, + )[0]?.[0] ?? 15; +}; + +export const buildBurstDetectionRequest = ( + parameters: BurstDetectionAnalysisParametersState, + network: string, +) => ({ + network, + scheme_name: parameters.schemeName.trim(), + sampling_interval_minutes: parameters.samplingIntervalMinutes, + ...(parameters.detectionMode === "historical" && parameters.targetTime + ? { target_time: parameters.targetTime.toISOString() } + : {}), +}); + const AnalysisParameters: React.FC<Props> = ({ onResult, state, onStateChange, }) => { const { open } = useNotification(); - const [parametersState, setParametersState, setFormField] = useControllableObjectState( - state, - onStateChange, - createBurstDetectionAnalysisParametersState(), - ); + const [parametersState, setParametersState, setFormField] = + useControllableObjectState( + state, + onStateChange, + createBurstDetectionAnalysisParametersState(), + ); const { schemeName, - dataSource, - schemes, - selectedSchemeId, - scadaStart, - scadaEnd, - mu, - pointsPerDay, - nEstimators, - contaminationInput, - advancedOpen, + detectionMode, + targetTime, + samplingIntervalMinutes, + samplingIntervalSource, } = parametersState; - const [schemeLoading, setSchemeLoading] = useState(false); const [running, setRunning] = useState(false); - const isSimulationMode = dataSource === "simulation"; + const [frequencyLoading, setFrequencyLoading] = useState(false); - const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { - const start = dayjs(scheme.scheme_start_time); - const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600; - const end = start.add(durationSeconds, "second"); - - setParametersState((previous) => ({ - ...previous, - scadaStart: start, - scadaEnd: end, - })); - }, [setParametersState]); - - const fetchSchemes = useCallback( - async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { - if (schemeLoading || (!force && schemes.length > 0)) return; - - setSchemeLoading(true); - try { - const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { - params: { network: NETWORK_NAME }, - }); - const burstSchemes = (response.data as SchemeItem[]).filter( - (scheme) => scheme.scheme_type === "burst_analysis", - ).sort( - (a, b) => - dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(), + useEffect(() => { + if (samplingIntervalSource !== "metadata") return; + let active = true; + setFrequencyLoading(true); + api + .get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } }) + .then((response) => { + if (!active) return; + const interval = resolvePressureSamplingInterval( + response.data as ScadaInfoItem[], ); + setParametersState((previous) => + previous.samplingIntervalSource === "metadata" + ? { ...previous, samplingIntervalMinutes: interval } + : previous, + ); + }) + .catch(() => { + // Keep the 15-minute fallback when SCADA metadata is unavailable. + }) + .finally(() => { + if (active) setFrequencyLoading(false); + }); + return () => { + active = false; + }; + }, [samplingIntervalSource, setParametersState]); - setFormField("schemes", burstSchemes); + const samplingIntervalValid = + Number.isInteger(samplingIntervalMinutes) && + samplingIntervalMinutes > 0 && + 1440 % samplingIntervalMinutes === 0; - if (selectedSchemeId) { - const matchedScheme = burstSchemes.find( - (scheme) => scheme.scheme_id === selectedSchemeId, - ); - if (matchedScheme) { - applySchemeTimeRange(matchedScheme); - } else { - setFormField("selectedSchemeId", ""); - } - } - - if (notify) { - open?.({ - type: "success", - message: "方案列表已刷新", - description: `当前可选爆管分析方案 ${burstSchemes.length} 个`, - }); - } - } catch (error: any) { - open?.({ - type: "error", - message: "刷新方案失败", - description: - error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表", - }); - } finally { - setSchemeLoading(false); - } - }, - [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField], + const isValid = useMemo( + () => + schemeName.trim().length > 0 && + samplingIntervalValid && + (detectionMode === "latest" || Boolean(targetTime?.isValid())), + [detectionMode, samplingIntervalValid, schemeName, targetTime], ); - const handleDataSourceChange = (value: "monitoring" | "simulation") => { - setFormField("dataSource", value); - if (value === "simulation") { - void fetchSchemes(); - } - }; - - const handleSchemeSelect = (schemeId: number) => { - setFormField("selectedSchemeId", schemeId); - const scheme = schemes.find((item) => item.scheme_id === schemeId); - if (scheme) { - applySchemeTimeRange(scheme); - } - }; - - const timeWindowValid = useMemo(() => { - if (!scadaStart || !scadaEnd) return false; - return scadaEnd.diff(scadaStart, "day", true) >= 2; - }, [scadaEnd, scadaStart]); - - const contaminationValue = useMemo(() => { - const normalized = contaminationInput.trim().toLowerCase(); - if (!normalized || normalized === "auto") { - return "auto" as const; - } - const parsed = Number(normalized); - if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) { - return null; - } - return parsed; - }, [contaminationInput]); - - const isValid = - Boolean(scadaStart && scadaEnd) && - timeWindowValid && - Number.isFinite(mu) && - mu > 0 && - Number.isFinite(pointsPerDay) && - pointsPerDay > 0 && - Number.isFinite(nEstimators) && - nEstimators > 0 && - contaminationValue !== null && - (dataSource !== "simulation" || Boolean(selectedSchemeId)); - const handleRun = async () => { - if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) { + if (!isValid) { open?.({ type: "error", message: "参数不完整", - description: "请检查时间范围(至少2天)和高级参数是否填写正确。", + description: "请输入方案名称,并检查历史目标时间。", }); return; } @@ -222,50 +180,23 @@ const AnalysisParameters: React.FC<Props> = ({ key: "burst-detection-analysis-progress", type: "progress", message: "正在执行爆管侦测", - description: "正在读取数据并计算异常分数。", + description: "正在读取目标时刻及前 14 天同刻基线。", undoableTimeout: 3, }); try { - const selectedScheme = - dataSource === "simulation" - ? schemes.find((item) => item.scheme_id === selectedSchemeId) - : undefined; - - const response = await api.post("/api/v1/burst-detection/detect/", { - network: NETWORK_NAME, - data_source: dataSource, - scheme_name: schemeName.trim() || undefined, - scada_start: scadaStart.toISOString(), - scada_end: scadaEnd.toISOString(), - mu, - points_per_day: pointsPerDay, - iforest_params: { - n_estimators: nEstimators, - contamination: contaminationValue, - }, - simulation_scheme_name: selectedScheme?.scheme_name, - simulation_scheme_type: selectedScheme?.scheme_type, - }); - - onResult({ - ...(response.data as BurstDetectionResult), - scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name, - algorithm_params: { - mu, - points_per_day: pointsPerDay, - iforest_params: { - n_estimators: nEstimators, - contamination: contaminationValue, - }, - }, - }); - + const response = await api.post( + "/api/v1/burst-detection/detect/", + buildBurstDetectionRequest(parametersState, NETWORK_NAME), + ); + onResult(response.data as BurstDetectionResult); open?.({ key: "burst-detection-analysis-success", type: "success", message: "爆管侦测完成", - description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`, + description: response.data.summary?.burst_detected + ? "目标时刻存在异常信号,请优先复核相关测点。" + : "目标时刻未发现爆管异常。", }); } catch (error: any) { open?.({ @@ -280,7 +211,7 @@ const AnalysisParameters: React.FC<Props> = ({ }; return ( - <Box className="flex flex-col flex-1 min-h-0"> + <Box className="flex min-h-0 flex-1 flex-col"> <Box className="flex flex-col gap-3"> <Box> <Typography variant="subtitle2" className="mb-1 font-medium"> @@ -297,211 +228,89 @@ const AnalysisParameters: React.FC<Props> = ({ <Box> <Typography variant="subtitle2" className="mb-1 font-medium"> - 数据来源 + 侦测方式 </Typography> <FormControl fullWidth size="small"> <Select - value={dataSource} - onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")} + value={detectionMode} + onChange={(event) => + setFormField( + "detectionMode", + event.target.value as "latest" | "historical", + ) + } > - <MenuItem value="monitoring">监测数据</MenuItem> - <MenuItem value="simulation">模拟方案</MenuItem> + <MenuItem value="latest">检测最新数据</MenuItem> + <MenuItem value="historical">历史时刻回放</MenuItem> </Select> </FormControl> </Box> - {isSimulationMode && ( - <Box> - <Typography variant="subtitle2" className="mb-1 font-medium"> - 选择爆管分析方案 - </Typography> - <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> - <FormControl fullWidth size="small"> - <Select - value={selectedSchemeId} - onChange={(e) => handleSchemeSelect(Number(e.target.value))} - disabled={schemeLoading} - displayEmpty - > - <MenuItem value="" disabled> - 请选择方案 - </MenuItem> - {schemes.map((scheme) => ( - <MenuItem key={scheme.scheme_id} value={scheme.scheme_id}> - {scheme.scheme_name} - </MenuItem> - ))} - </Select> - </FormControl> - <IconButton - size="small" - color="primary" - onClick={() => void fetchSchemes({ force: true, notify: true })} - disabled={schemeLoading} - aria-label="刷新爆管分析方案" - sx={{ - border: "1px solid", - borderColor: "divider", - borderRadius: 1, - }} - > - {schemeLoading ? ( - <CircularProgress size={18} color="inherit" /> - ) : ( - <RefreshIcon fontSize="small" /> - )} - </IconButton> - </Box> - </Box> - )} - - <LocalizationProvider - dateAdapter={AdapterDayjs} - adapterLocale="zh-cn" - localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText} - > - <Box className="grid grid-cols-2 gap-2"> - <Box> - <Typography variant="subtitle2" className="mb-1 font-medium"> - 侦测开始时间 - </Typography> - <DateTimePicker - value={scadaStart} - onChange={(value) => setFormField("scadaStart", value)} - maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined} - disabled={isSimulationMode} - format="YYYY-MM-DD HH:mm" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - </Box> - <Box> - <Typography variant="subtitle2" className="mb-1 font-medium"> - 侦测结束时间 - </Typography> - <DateTimePicker - value={scadaEnd} - onChange={(value) => setFormField("scadaEnd", value)} - minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined} - disabled={isSimulationMode} - format="YYYY-MM-DD HH:mm" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - </Box> - </Box> - </LocalizationProvider> - - <Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900"> - 当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。 - </Box> - - <Box - sx={{ - border: "1px solid", - borderColor: "grey.200", - borderRadius: 1, - overflow: "hidden", - }} - > - <Box - role="button" - tabIndex={0} - onClick={() => setFormField("advancedOpen", !advancedOpen)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - setFormField("advancedOpen", !advancedOpen); - } - }} - sx={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - px: 1.25, - py: 0.75, - cursor: "pointer", - backgroundColor: "transparent", - "&:hover": { backgroundColor: "action.hover" }, - }} + {detectionMode === "historical" ? ( + <LocalizationProvider + dateAdapter={AdapterDayjs} + adapterLocale="zh-cn" + localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText} > - <Typography variant="body2" color="text.secondary"> - 高级参数 - </Typography> - <ExpandMoreIcon - sx={{ - transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)", - transition: "transform 0.2s ease", - }} - /> - </Box> - <Collapse in={advancedOpen} timeout="auto" unmountOnExit> - <Box - sx={{ - px: 1.25, - pt: 1.25, - pb: 1.25, - backgroundColor: "transparent", - }} - > - <Box className="flex flex-col gap-3"> - <TextField - type="number" - label="频域截断系数" - value={mu} - onChange={(event) => setFormField("mu", Number(event.target.value))} - size="small" - fullWidth - inputProps={{ min: 1 }} - /> - <TextField - type="number" - label="每日采样点数" - value={pointsPerDay} - onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))} - size="small" - fullWidth - inputProps={{ min: 1 }} - /> - <TextField - type="number" - label="孤立森林树数量" - value={nEstimators} - onChange={(event) => setFormField("nEstimators", Number(event.target.value))} - size="small" - fullWidth - inputProps={{ min: 1 }} - /> - <TextField - label="异常比例" - value={contaminationInput} - onChange={(event) => setFormField("contaminationInput", event.target.value)} - size="small" - fullWidth - helperText="填写 auto 或 0~0.5 之间的小数。" - error={contaminationValue === null} - /> - </Box> + <Box> + <Typography variant="subtitle2" className="mb-1 font-medium"> + 目标时刻 + </Typography> + <DateTimePicker + value={targetTime} + onChange={(value) => setFormField("targetTime", value)} + maxDateTime={dayjs()} + minutesStep={15} + format="YYYY-MM-DD HH:mm" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> </Box> - </Collapse> - </Box> + </LocalizationProvider> + ) : null} + + <TextField + type="number" + label="采样间隔(分钟)" + value={samplingIntervalMinutes} + onChange={(event) => { + setParametersState((previous) => ({ + ...previous, + samplingIntervalMinutes: Number(event.target.value), + samplingIntervalSource: "manual", + })); + }} + size="small" + fullWidth + error={!samplingIntervalValid} + inputProps={{ min: 1, max: 1440, step: 1 }} + helperText={ + samplingIntervalValid + ? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点` + : "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。" + } + /> + + {detectionMode === "latest" ? ( + <Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900"> + 系统自动读取目标时刻及前 14 天同一时刻数据。每天使用截至该时刻的 + 24 小时压力序列提取扰动特征,仅判定目标时刻是否异常。 + </Box> + ) : null} + <Typography variant="caption" color="text.secondary"> + 当前口径:{samplingIntervalMinutes || "-"} 分钟采样、 + {samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} 点/天、14 + 个参考日;缺失数据的测点不会插值,将从本次分析中排除。 + </Typography> </Box> - <Box className="mt-auto pt-3 flex gap-2"> + <Box className="mt-auto flex gap-2 pt-3"> <Button variant="outlined" fullWidth disabled={running} - sx={{ textTransform: "none", fontWeight: 500 }} - onClick={() => { - setParametersState((previous) => ({ - ...previous, - schemeName: `Burst_Detection_${Date.now()}`, - scadaStart: dayjs().subtract(3, "day"), - scadaEnd: dayjs(), - mu: 100, - pointsPerDay: 96, - nEstimators: 50, - contaminationInput: "auto", - })); - }} + onClick={() => + setParametersState(createBurstDetectionAnalysisParametersState()) + } > 重置 </Button> @@ -509,11 +318,13 @@ const AnalysisParameters: React.FC<Props> = ({ variant="contained" fullWidth disabled={!isValid || running} - onClick={handleRun} - className="bg-blue-600 hover:bg-blue-700" - sx={{ textTransform: "none", fontWeight: 500 }} + onClick={() => void handleRun()} > - {running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"} + {running + ? "侦测中..." + : detectionMode === "latest" + ? "侦测最新数据" + : "回放目标时刻"} </Button> </Box> </Box> diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx index af2a583..6c183c5 100644 --- a/src/components/olmap/BurstDetection/DetectionResults.tsx +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -5,23 +5,23 @@ import { Box, Button, Chip, Tooltip, Typography } from "@mui/material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { zhCN } from "@mui/x-data-grid/locales"; import { + CheckCircleOutline as CheckCircleIcon, + ErrorOutline as ErrorOutlineIcon, FormatListBulleted, InfoOutlined as InfoOutlinedIcon, Room as RoomIcon, ShowChart as ShowChartIcon, - CheckCircleOutline as CheckCircleIcon, - ErrorOutline as ErrorOutlineIcon, } from "@mui/icons-material"; import ReactECharts from "echarts-for-react"; import dayjs from "dayjs"; -import { useMap } from "@components/olmap/core/MapComponent"; -import { queryFeaturesByIds } from "@/utils/mapQueryService"; -import { GeoJSON } from "ol/format"; import Feature from "ol/Feature"; +import { GeoJSON } from "ol/format"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Circle, Fill, Stroke, Style } from "ol/style"; import { bbox, featureCollection } from "@turf/turf"; +import { useMap } from "@components/olmap/core/MapComponent"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { BurstDetectionResult, BurstDetectionRow } from "./types"; export interface BurstDetectionResultsState { @@ -29,9 +29,7 @@ export interface BurstDetectionResultsState { } export const createBurstDetectionResultsState = - (): BurstDetectionResultsState => ({ - selectedDay: null, - }); + (): BurstDetectionResultsState => ({ selectedDay: null }); interface Props { result: BurstDetectionResult | null; @@ -46,54 +44,33 @@ interface MetricCardProps { tone: "blue" | "orange" | "purple" | "green"; } -const toneStyles: Record< - MetricCardProps["tone"], - { bg: string; border: string; text: string; darkText: string } -> = { - blue: { - bg: "from-blue-50 to-blue-100", - border: "border-blue-200", - text: "text-blue-700", - darkText: "text-blue-900", - }, - orange: { - bg: "from-orange-50 to-orange-100", - border: "border-orange-200", - text: "text-orange-700", - darkText: "text-orange-900", - }, - purple: { - bg: "from-purple-50 to-purple-100", - border: "border-purple-200", - text: "text-purple-700", - darkText: "text-purple-900", - }, - green: { - bg: "from-green-50 to-green-100", - border: "border-green-200", - text: "text-green-700", - darkText: "text-green-900", - }, +const toneStyles: Record<MetricCardProps["tone"], string> = { + blue: "border-blue-200 from-blue-50 to-blue-100 text-blue-900", + orange: "border-orange-200 from-orange-50 to-orange-100 text-orange-900", + purple: "border-purple-200 from-purple-50 to-purple-100 text-purple-900", + green: "border-green-200 from-green-50 to-green-100 text-green-900", }; -const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { - const style = toneStyles[tone]; - return ( - <Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}> - <Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}> - {label} +const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => ( + <Box + className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${toneStyles[tone]}`} + > + <Typography variant="caption" className="mb-1 block font-semibold"> + {label} + </Typography> + <Typography variant="body2" className="font-bold"> + {value} + </Typography> + {hint ? ( + <Typography variant="caption" className="mt-0.5 block opacity-75"> + {hint} </Typography> - <Typography variant="body2" className={`font-bold ${style.darkText}`}> - {value} - </Typography> - {hint ? ( - <Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}> - {hint} - </Typography> - ) : null} - </Box> - ); -}; + ) : null} + </Box> +); + +const formatDateTime = (value?: string) => + value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"; const EmptyState = () => ( <Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> @@ -104,42 +81,27 @@ const EmptyState = () => ( 等待侦测结果 </Typography> <Typography variant="body2" className="max-w-xs text-gray-500"> - 提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。 + 提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。 </Typography> </Box> ); -const getScoreLevel = (score: number) => { - if (score <= -0.6) return { label: "高风险", color: "error" as const }; - if (score <= -0.2) return { label: "需关注", color: "warning" as const }; - return { label: "正常", color: "success" as const }; -}; - -const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"); - -const DetectionResults: React.FC<Props> = ({ - result, - state, - onStateChange, -}) => { +const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => { const map = useMap(); const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); - const [internalResultsState, setInternalResultsState] = - useState<BurstDetectionResultsState>(createBurstDetectionResultsState); - const resultsState = state ?? internalResultsState; - const selectedDay = resultsState.selectedDay; - const setSelectedDay = (value: number | null) => { - const nextState = { ...resultsState, selectedDay: value }; - if (state === undefined) { - setInternalResultsState(nextState); - } + const [internalState, setInternalState] = useState<BurstDetectionResultsState>( + createBurstDetectionResultsState, + ); + const resultsState = state ?? internalState; + const setSelectedDay = (selectedDay: number | null) => { + const nextState = { selectedDay }; + if (state === undefined) setInternalState(nextState); onStateChange?.(nextState); }; useEffect(() => { if (!map) return; - const layer = new VectorLayer({ source: new VectorSource(), style: new Style({ @@ -157,10 +119,8 @@ const DetectionResults: React.FC<Props> = ({ queryable: false, }, }); - map.addLayer(layer); highlightLayerRef.current = layer; - return () => { highlightLayerRef.current = null; map.removeLayer(layer); @@ -174,58 +134,67 @@ const DetectionResults: React.FC<Props> = ({ highlightFeatures.forEach((feature) => source.addFeature(feature)); }, [highlightFeatures]); - const defaultSelectedDay = useMemo( - () => - result?.summary?.most_anomalous_day ?? - result?.summary?.latest_day?.Day ?? - result?.rows[0]?.Day ?? - null, + const sortedRows = useMemo( + () => [...(result?.rows ?? [])].sort((a, b) => a.Day - b.Day), [result], ); - const activeSelectedDay = selectedDay ?? defaultSelectedDay; + const timestampForRow = (row: BurstDetectionRow) => { + if (row.Timestamp) return row.Timestamp; + const start = dayjs(result?.scada_window?.start); + return start.isValid() ? start.add(row.Day, "day").toISOString() : undefined; + }; - const selectedRow = useMemo<BurstDetectionRow | null>(() => { - if (!result || activeSelectedDay === null) return null; - return result.rows.find((row) => row.Day === activeSelectedDay) ?? null; - }, [activeSelectedDay, result]); - - const scoreSeries = useMemo( - () => - result?.rows.map((row) => ({ - value: [row.Day, Number(row.Score.toFixed(4))], + const scoreThreshold = result?.summary.score_threshold ?? 0; + const scoreSeries = sortedRows.map((row) => ({ + day: row.Day, + value: [ + timestampForRow(row) + ? dayjs(timestampForRow(row)).format("MM-DD HH:mm") + : `第 ${row.Day} 天`, + Number(row.Score.toFixed(4)), + ], itemStyle: { - color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981", + color: + row.Role === "target" + ? row.IsBurst + ? "#ef4444" + : "#2563eb" + : "#94a3b8", }, - })) ?? [], - [result], - ); + symbolSize: row.Role === "target" ? 11 : 7, + })); const rankingSeries = useMemo( () => - [...(result?.summary?.latest_sensor_rankings ?? [])] - .sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value) + [...(result?.summary.latest_sensor_rankings ?? [])] + .sort( + (a, b) => + (a.standardized_deviation ?? a.latest_high_frequency_value) - + (b.standardized_deviation ?? b.latest_high_frequency_value), + ) .map((item) => ({ name: item.sensor_node, - value: Number(item.latest_high_frequency_value.toFixed(4)), + value: Number( + (item.standardized_deviation ?? item.latest_high_frequency_value).toFixed(3), + ), })), [result], ); const locateSensors = async (sensorIds: string[]) => { if (!map || sensorIds.length === 0) return; - let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat"); if (features.length === 0) { features = await queryFeaturesByIds(sensorIds, "geo_junctions"); } if (features.length === 0) return; - setHighlightFeatures(features); - - const geojsonFormat = new GeoJSON(); - const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); - // @ts-ignore turf typing with ol geojson objects + const format = new GeoJSON(); + const geojsonFeatures = features.map((feature) => + format.writeFeatureObject(feature), + ); + // @ts-ignore turf accepts OpenLayers GeoJSON feature objects const extent = bbox(featureCollection(geojsonFeatures)); map.getView().fit(extent, { maxZoom: 18, @@ -234,60 +203,50 @@ const DetectionResults: React.FC<Props> = ({ }); }; - if (!result) { - return <EmptyState />; - } + if (!result) return <EmptyState />; - const latestDay = result.summary?.latest_day; - const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0); - const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null; - const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0); + const targetRow = sortedRows.find((row) => row.Role === "target") ?? sortedRows.at(-1); const isBurstDetected = result.summary.burst_detected; + const targetRank = result.summary.target_rank; + const excludedCount = result.data_quality?.excluded_sensors.length ?? 0; const chartOption = { tooltip: { trigger: "axis", - formatter: (params: Array<{ data: { value: [number, number] } }>) => { - const point = params[0]?.data?.value; - if (!point) return "-"; - return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`; + formatter: (params: Array<{ data: { day: number; value: [string, number] } }>) => { + const data = params[0]?.data; + return data + ? `${data.value[0]}<br/>${data.day === 15 ? "目标时刻" : "参考日"}<br/>异常分数:${data.value[1]}` + : "-"; }, }, - grid: { top: 30, left: 40, right: 20, bottom: 35 }, + grid: { top: 30, left: 48, right: 20, bottom: 48 }, xAxis: { type: "category", - name: "侦测日", - data: result.rows.map((row) => row.Day), - axisLabel: { fontSize: 10 }, - }, - yAxis: { - type: "value", - name: "异常分数", - axisLabel: { fontSize: 10 }, + name: "同刻日期", + boundaryGap: false, + data: scoreSeries.map((item) => item.value[0]), + axisLabel: { fontSize: 10, interval: 2, rotate: 25 }, }, + yAxis: { type: "value", name: "异常分数", axisLabel: { fontSize: 10 } }, series: [ { type: "line", - smooth: true, - symbolSize: 8, data: scoreSeries, - lineStyle: { color: "#2563eb", width: 2 }, + lineStyle: { color: "#94a3b8", width: 2 }, markLine: { symbol: "none", - lineStyle: { type: "dashed", color: "#94a3b8" }, - data: [{ yAxis: 0 }], + lineStyle: { type: "dashed", color: "#ef4444" }, + data: [{ yAxis: scoreThreshold, name: "报警阈值" }], }, }, ], }; const rankingOption = { - tooltip: { - trigger: "axis", - axisPointer: { type: "shadow" }, - }, - grid: { top: 20, left: 70, right: 20, bottom: 20 }, - xAxis: { type: "value", axisLabel: { fontSize: 10 } }, + tooltip: { trigger: "axis", axisPointer: { type: "shadow" } }, + grid: { top: 12, left: 82, right: 20, bottom: 25 }, + xAxis: { type: "value", name: "标准化偏离", axisLabel: { fontSize: 10 } }, yAxis: { type: "category", data: rankingSeries.map((item) => item.name), @@ -298,9 +257,7 @@ const DetectionResults: React.FC<Props> = ({ type: "bar", data: rankingSeries.map((item) => ({ value: item.value, - itemStyle: { - color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981", - }, + itemStyle: { color: item.value < 0 ? "#ef4444" : "#f59e0b" }, })), barWidth: 14, }, @@ -309,323 +266,176 @@ const DetectionResults: React.FC<Props> = ({ const columns: GridColDef[] = [ { - field: "Day", - headerName: "侦测日", - width: 96, - valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"), + field: "Timestamp", + headerName: "同刻日期", + minWidth: 145, + flex: 1, + valueGetter: (_value, row) => formatDateTime(timestampForRow(row)), + }, + { + field: "Role", + headerName: "角色", + width: 90, + valueFormatter: (value?: string) => (value === "target" ? "目标" : "参考"), }, { field: "Score", headerName: "异常分数", - width: 120, - valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"), + width: 110, + valueFormatter: (value?: number) => + typeof value === "number" ? value.toFixed(4) : "-", }, { field: "IsBurst", - headerName: "判定结果", - width: 120, - renderCell: ({ value }) => { - const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const }; - return <Chip size="small" label={level.label} color={level.color} variant="outlined" />; - }, + headerName: "目标判定", + width: 110, + renderCell: ({ value, row }) => + row.Role === "target" || row.Day === result.day_count ? ( + <Chip + size="small" + label={value ? "爆管异常" : "正常"} + color={value ? "error" : "success"} + variant="outlined" + /> + ) : ( + <Typography variant="caption" color="text.secondary"> + 参考 + </Typography> + ), }, ]; - - const rows = result.rows.map((row) => ({ id: row.Day, ...row })); + const tableRows = sortedRows.map((row) => ({ id: row.Day, ...row })); return ( <Box className="h-full overflow-auto p-1"> <Box className="mb-4 space-y-3"> - {/* Status Banner */} <Box - className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected - ? "bg-red-50 border-red-100 text-red-900" - : "bg-green-50 border-green-100 text-green-900" - }`} + className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${ + isBurstDetected + ? "border-red-100 bg-red-50 text-red-900" + : "border-green-100 bg-green-50 text-green-900" + }`} > - {isBurstDetected ? ( - <ErrorOutlineIcon className="text-red-600" /> - ) : ( - <CheckCircleIcon className="text-green-600" /> - )} + {isBurstDetected ? <ErrorOutlineIcon /> : <CheckCircleIcon />} <Box className="flex-1"> <Typography variant="subtitle2" className="font-bold"> - {isBurstDetected - ? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)` - : "未侦测到爆管异常"} + {isBurstDetected ? "目标时刻侦测到爆管异常" : "目标时刻未侦测到爆管异常"} </Typography> <Typography variant="caption" className="opacity-80"> - {isBurstDetected - ? "建议检查异常日期的压力波动情况" - : "当前时间窗口内数据特征平稳,符合历史模式"} + 目标:{formatDateTime(result.target_time ?? result.summary.target_time)};分数越低越异常 </Typography> </Box> </Box> - {/* Header */} - <Box className="flex items-center justify-between px-1"> - <Box className="flex items-center gap-2"> - <Box className="h-4 w-1 rounded-full bg-blue-600" /> - <Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}> - {result.scheme_name || "爆管侦测结果"} - </Typography> - </Box> - <Box className="flex items-center gap-2"> - {result.username ? ( - <Chip - label={result.username} - size="small" - sx={{ - height: 24, - backgroundColor: "#f3f4f6", - color: "#4b5563", - border: "none", - fontWeight: 500, - }} - /> - ) : null} - <Button - size="small" - variant="outlined" - startIcon={<RoomIcon />} - onClick={() => - locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5)) - } - sx={{ - height: 24, - minWidth: 0, - padding: "0 8px", - borderColor: "#bfdbfe", - color: "#2563eb", - fontSize: "0.75rem", - "&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" }, - }} - > - 定位 - </Button> - </Box> + <Box className="flex items-center justify-between gap-2 px-1"> + <Typography variant="h6" className="min-w-0 flex-1 font-bold text-gray-900"> + 爆管侦测结果 + </Typography> + <Button + size="small" + variant="outlined" + startIcon={<RoomIcon />} + onClick={() => + void locateSensors( + result.summary.latest_sensor_rankings + .slice(0, 5) + .map((item) => item.sensor_node), + ) + } + sx={{ flexShrink: 0, whiteSpace: "nowrap" }} + > + 定位异常测点 + </Button> </Box> - {/* Configuration Summary */} - <Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600"> - <Box className="flex items-center gap-1.5"> - <Box className="h-1.5 w-1.5 rounded-full bg-blue-400" /> - <span className="font-medium text-gray-700">时间窗口:</span> - <span className="font-mono text-gray-600"> - {formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)} - </span> - </Box> - <Box className="flex items-center gap-1.5"> - <Box className="h-1.5 w-1.5 rounded-full bg-purple-400" /> - <span className="font-medium text-gray-700">数据来源:</span> - <span className="text-gray-600"> - {(() => { - const ds = result.data_source; - const os = result.observed_source; - if (ds === "simulation") return "模拟数据"; - if (ds === "monitoring") return "监测数据"; - if (os === "simulation_scheme_timerange") return "模拟数据"; - if (os === "backend_timerange") return "监测数据"; - return os || "-"; - })()} - </span> - </Box> - </Box> - - {/* Metrics Grid */} <Box className="grid grid-cols-2 gap-3"> <MetricCard - label="异常天数" - value={`${result.summary.anomaly_day_count} / ${result.day_count}`} - hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`} - tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"} + label="目标异常分数" + value={targetRow ? targetRow.Score.toFixed(4) : "-"} + hint={`报警阈值 ≤ ${scoreThreshold.toFixed(2)}`} + tone={isBurstDetected ? "orange" : "green"} /> <MetricCard - label="最异常日" - value={ - result.summary.burst_detected && result.summary.most_anomalous_day - ? `第 ${result.summary.most_anomalous_day} 天` - : "无" - } - hint={ - result.summary.burst_detected && mostAnomalousRow - ? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}` - : "-" - } + label="目标异常排名" + value={targetRank ? `${targetRank} / ${result.day_count}` : "-"} + hint="在目标日与 14 个参考日中排序" tone="purple" /> <MetricCard - label="最新状态" - value={latestLevel.label} - hint={latestDay ? `第 ${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"} - tone={latestLevel.color === "success" ? "green" : "orange"} + label="参考区间" + value={`${formatDateTime(result.reference_window?.start)} ~ ${formatDateTime(result.reference_window?.end)}`} + hint={`${result.reference_window?.day_count ?? 14} 个同刻参考日`} + tone="blue" /> <MetricCard - label="测点 / 样本" - value={`${result.sensor_nodes.length} / ${result.sample_count}`} - hint={`每日采样点数:${result.points_per_day}`} + label="有效 / 排除测点" + value={`${result.sensor_nodes.length} / ${excludedCount}`} + hint={`${result.sampling_interval_minutes ?? 15} 分钟采样,${result.points_per_day} 点/天`} tone="blue" /> </Box> </Box> - {/* Score Trend Chart */} <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> - <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> + <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3"> <Box className="flex items-center gap-2"> - <ShowChartIcon className="h-5 w-5 text-blue-600" /> - <Typography variant="subtitle1" className="font-bold text-gray-800"> - 异常分数趋势 + <ShowChartIcon className="text-blue-600" /> + <Typography variant="subtitle1" className="font-bold"> + 15 天同刻异常分数 </Typography> </Box> - <Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。"> + <Tooltip title="灰色点为前 14 天参考,最后一个点为本次目标。"> <InfoOutlinedIcon fontSize="small" className="text-gray-400" /> </Tooltip> </Box> - <Box sx={{ height: 250, px: 1.5, py: 1 }}> + <Box sx={{ height: 270, px: 1.5, py: 1 }}> <ReactECharts option={chartOption} style={{ height: "100%", width: "100%" }} onEvents={{ - click: (params: { data?: { value?: [number, number] } }) => { - const day = params?.data?.value?.[0]; - if (typeof day === "number") { - setSelectedDay(day); - } - }, + click: (params: { data?: { day?: number } }) => + setSelectedDay(params.data?.day ?? null), }} /> </Box> </Box> - {/* Selected Day Interpretation */} - {/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> - <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> - <Typography variant="subtitle1" className="font-bold text-gray-800"> - 选中日解读 - </Typography> - {selectedRow ? ( - <Chip - size="small" - label={`第 ${selectedRow.Day} 天`} - sx={{ - height: 22, - backgroundColor: "rgba(37, 99, 235, 0.08)", - color: "#2563eb", - fontWeight: 600, - fontSize: "0.75rem", - border: "none", - }} - /> - ) : null} - </Box> - {selectedRow ? ( - <Box className="space-y-3 px-4 py-3"> - <Box className="flex items-center gap-2"> - <Chip - label={getScoreLevel(selectedRow.Score).label} - color={getScoreLevel(selectedRow.Score).color} - variant="filled" - /> - </Box> - <Typography variant="body2" className="text-gray-700"> - 异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span> + {rankingSeries.length > 0 ? ( + <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> + <Box className="flex items-center justify-between border-b border-gray-100 px-4 py-3"> + <Typography variant="subtitle1" className="font-bold"> + 目标测点压力偏离 </Typography> - <Typography variant="body2" className="text-gray-700"> - 模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1)" : "正常日(Prediction = 1)"} - </Typography> - <Typography variant="body2" className="text-gray-700"> - 解读建议: - {selectedRow.Score <= -0.6 - ? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。" - : selectedRow.Score <= -0.2 - ? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。" - : "未见明显异常,可作为基线日参考。"} + <Typography variant="caption" color="text.secondary"> + 负值越小,压降相对历史越明显 </Typography> </Box> - ) : ( - <Typography variant="body2" className="px-4 py-3 text-gray-500"> - 请在趋势图或表格中选择一天查看详细解释。 - </Typography> - )} - </Box> */} + <Box sx={{ height: 280, px: 1.5, py: 1 }}> + <ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} /> + </Box> + </Box> + ) : null} - {/* Latest Sensor Rankings */} - {/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> - <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> - <Typography variant="subtitle1" className="font-bold text-gray-800"> - 最新测点高频特征排名 - </Typography> - <Typography variant="caption" className="text-gray-500"> - 仅展示最新一天 - </Typography> - </Box> - <Box sx={{ height: 260, px: 1.5, py: 1 }}> - <ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} /> - </Box> - <Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3"> - {result.summary.latest_sensor_rankings.slice(0, 5).map((item) => ( - <Button - key={item.sensor_node} - size="small" - variant="outlined" - onClick={() => locateSensors([item.sensor_node])} - sx={{ - borderColor: "#bfdbfe", - color: "#2563eb", - "&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" }, - }} - > - {item.sensor_node} - </Button> - ))} - </Box> - </Box> */} - - {/* Results Table */} <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"> - <Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3"> - <Box className="flex items-center gap-2"> - <FormatListBulleted className="h-5 w-5 text-blue-600" /> - <Typography variant="subtitle1" className="font-bold text-gray-800"> - 结果表格 - </Typography> - </Box> - <Chip - size="small" - label={`${rows.length} 条`} - sx={{ - height: 22, - backgroundColor: "rgba(37, 99, 235, 0.08)", - color: "#2563eb", - fontWeight: 600, - fontSize: "0.75rem", - border: "none", - }} - /> + <Box className="flex items-center gap-2 border-b border-gray-100 px-4 py-3"> + <FormatListBulleted className="text-blue-600" /> + <Typography variant="subtitle1" className="font-bold"> + 同刻对照明细 + </Typography> </Box> - <Box sx={{ height: 320, px: 1, py: 1 }}> + <Box sx={{ height: 360, px: 1, py: 1 }}> <DataGrid - rows={rows} + rows={tableRows} columns={columns} - columnBufferPx={100} localeText={zhCN.components.MuiDataGrid.defaultProps.localeText} - initialState={{ - pagination: { paginationModel: { pageSize: 50, page: 0 } }, - }} - pageSizeOptions={[50]} - hideFooterSelectedRowCount - sx={{ - border: "none", - "& .MuiDataGrid-cell": { borderColor: "#f0f0f0" }, - "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" }, - "& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" }, - // Hide the rows per page selector since it's fixed to 50 - "& .MuiTablePagination-selectLabel": { display: "none" }, - "& .MuiTablePagination-input": { display: "none" }, - }} + pageSizeOptions={[15]} + initialState={{ pagination: { paginationModel: { pageSize: 15, page: 0 } } }} disableRowSelectionOnClick onRowClick={(params) => setSelectedDay(Number(params.row.Day))} + getRowClassName={(params) => + params.row.Day === resultsState.selectedDay ? "bg-blue-50" : "" + } + sx={{ border: "none" }} /> </Box> </Box> diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx index 6a44e7a..3c696c0 100644 --- a/src/components/olmap/BurstDetection/SchemeQuery.tsx +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -115,6 +115,12 @@ const SchemeQuery: React.FC<Props> = ({ username: payload?.username ?? scheme.username, create_time: payload?.create_time ?? scheme.create_time, algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params, + requested_target_time: payload?.requested_target_time, + target_time: payload?.target_time, + reference_window: payload?.reference_window, + sampling_interval_minutes: payload?.sampling_interval_minutes, + daily_scores: payload?.daily_scores, + data_quality: payload?.data_quality, }; }; @@ -245,6 +251,9 @@ const SchemeQuery: React.FC<Props> = ({ const mostAnomalousDay = payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-"; const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0; + const targetTime = payload?.target_time ?? payload?.summary?.target_time; + const targetScore = payload?.summary?.target_score ?? summary?.target_score; + const targetRank = payload?.summary?.target_rank ?? summary?.target_rank; return ( <Card key={scheme.scheme_id} variant="outlined" className="transition-shadow hover:shadow-md"> @@ -293,30 +302,36 @@ const SchemeQuery: React.FC<Props> = ({ <Box className="grid grid-cols-3 gap-2"> <Box className="rounded bg-gray-50 p-2"> <Typography variant="caption" className="text-gray-500"> - 异常天数 + {targetTime ? "目标时刻" : "异常天数"} </Typography> <Typography variant="body2" className="font-semibold text-gray-900"> - {anomalyDayCount} + {targetTime ? dayjs(targetTime).format("MM-DD HH:mm") : anomalyDayCount} </Typography> </Box> <Box className="rounded bg-gray-50 p-2"> <Typography variant="caption" className="text-gray-500"> - 最异常日 + {targetTime ? "目标分数" : "最异常日"} </Typography> <Typography variant="body2" className="font-semibold text-gray-900"> - {isBurst - ? typeof mostAnomalousDay === "number" - ? `第 ${mostAnomalousDay} 天` - : mostAnomalousDay - : "无"} + {targetTime + ? typeof targetScore === "number" + ? targetScore.toFixed(4) + : "-" + : isBurst + ? typeof mostAnomalousDay === "number" + ? `第 ${mostAnomalousDay} 天` + : mostAnomalousDay + : "无"} </Typography> </Box> <Box className="rounded bg-gray-50 p-2"> <Typography variant="caption" className="text-gray-500"> - 测点数 + {targetTime ? "异常排名" : "测点数"} </Typography> <Typography variant="body2" className="font-semibold text-gray-900"> - {sensorCount} + {targetTime && targetRank + ? `${targetRank} / ${payload?.day_count ?? 15}` + : sensorCount} </Typography> </Box> </Box> @@ -336,6 +351,8 @@ const SchemeQuery: React.FC<Props> = ({ if (ds === "monitoring") return "监测数据"; if (os === "simulation_scheme_timerange") return "模拟数据"; if (os === "backend_timerange") return "监测数据"; + if (os === "latest_monitoring") return "最新监测数据"; + if (os === "historical_monitoring") return "历史监测回放"; return os || "-"; })()} </Typography> @@ -354,14 +371,16 @@ const SchemeQuery: React.FC<Props> = ({ </Box> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> <Typography variant="caption" className="text-gray-600"> - 算法参数: + 侦测口径: </Typography> <Typography variant="caption" className="font-medium text-gray-900"> - 频域截断系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} - ,每日采样点数: + 频域系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"} + ,每日采样: {scheme.scheme_detail?.algorithm_params?.points_per_day ?? payload?.algorithm_params?.points_per_day ?? "-"} + 点,阈值: + {payload?.summary?.score_threshold ?? "-"} </Typography> </Box> </Box> diff --git a/src/components/olmap/BurstDetection/types.ts b/src/components/olmap/BurstDetection/types.ts index 235edac..fa88561 100644 --- a/src/components/olmap/BurstDetection/types.ts +++ b/src/components/olmap/BurstDetection/types.ts @@ -3,11 +3,16 @@ export interface BurstDetectionRow { Score: number; Prediction: number; IsBurst: boolean; + Timestamp?: string; + Role?: "reference" | "target"; } export interface BurstDetectionSensorRanking { sensor_node: string; latest_high_frequency_value: number; + historical_mean?: number; + historical_std?: number; + standardized_deviation?: number; } export interface BurstDetectionSummary { @@ -17,6 +22,11 @@ export interface BurstDetectionSummary { anomaly_days: number[]; anomaly_day_count: number; latest_sensor_rankings: BurstDetectionSensorRanking[]; + target_score?: number; + score_threshold?: number; + target_rank?: number; + target_time?: string; + reference_day_count?: number; } export interface BurstDetectionAlgorithmParams { @@ -27,6 +37,7 @@ export interface BurstDetectionAlgorithmParams { contamination?: number | "auto"; random_state?: number; }; + score_threshold?: number; } export interface BurstDetectionResult { @@ -51,6 +62,25 @@ export interface BurstDetectionResult { type?: string; }; algorithm_params?: BurstDetectionAlgorithmParams; + requested_target_time?: string | null; + target_time?: string; + reference_window?: { + start: string; + end: string; + day_count: number; + }; + sampling_interval_minutes?: number; + daily_scores?: Array<{ + timestamp: string; + role: "reference" | "target"; + score: number; + raw_prediction: number; + }>; + data_quality?: { + included_sensors: string[]; + excluded_sensors: Array<{ sensor_node: string; reason: string }>; + minimum_required_sensors: number; + }; } export interface BurstDetectionSchemeDetail { -- 2.54.0 From 0d559f6130d7e57883fe451241c85c1b1b3af2f6 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Fri, 17 Jul 2026 16:27:40 +0800 Subject: [PATCH 237/281] fix(scada): show backend detail on cleaning errors --- src/components/olmap/SCADA/SCADADataPanel.tsx | 6 +++++- src/components/olmap/SCADA/SCADADeviceList.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index c8b4e40..adbeb05 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -557,7 +557,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ open?.({ type: "error", message: "数据清洗失败", - description: err.response?.data?.message || err.message || "未知错误", + description: + err.response?.data?.detail || + err.response?.data?.message || + err.message || + "未知错误", }); } finally { setIsCleaning(false); diff --git a/src/components/olmap/SCADA/SCADADeviceList.tsx b/src/components/olmap/SCADA/SCADADeviceList.tsx index ce8bcf3..84a93a8 100644 --- a/src/components/olmap/SCADA/SCADADeviceList.tsx +++ b/src/components/olmap/SCADA/SCADADeviceList.tsx @@ -645,7 +645,11 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ open?.({ type: "error", message: "数据清洗失败", - description: err.response?.data?.message || err.message || "未知错误", + description: + err.response?.data?.detail || + err.response?.data?.message || + err.message || + "未知错误", }); setIsCleaning(false); } -- 2.54.0 From 7a3677ee9fb0ed31416947bb80ab2a341493a617 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 20 Jul 2026 12:06:57 +0800 Subject: [PATCH 238/281] feat(config): inject frontend runtime config --- .dockerignore | 4 +- .gitea/workflows/package.yml | 9 ---- Dockerfile | 17 ++----- docker-compose.yml | 20 ++++----- docker/entrypoint.sh | 35 +++++++++++++++ src/app/api/auth/[...nextauth]/options.ts | 24 +++++++--- src/app/layout.tsx | 2 + src/components/header/index.tsx | 10 +++-- src/config/config.ts | 55 ++++++++++++++++------- src/contexts/ProjectContext.tsx | 22 ++++----- 10 files changed, 127 insertions(+), 71 deletions(-) create mode 100755 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore index 3cb48a6..5569a3f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,8 +3,10 @@ node_modules out build .git +.env +.env.* .env*.local README.md docker-compose.yml Dockerfile -.dockerignore \ No newline at end of file +.dockerignore diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index 17c3ffb..182d28c 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -105,15 +105,6 @@ jobs: -t "${IMAGE_NAME}:${IMAGE_TAG}" \ -t "${IMAGE_NAME}:latest" \ --build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \ - --build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \ - --build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \ - --build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \ - --build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \ - --build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \ - --build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \ - --build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \ - --build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \ - --build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \ . push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}" push_with_retry "${IMAGE_NAME}:latest" diff --git a/Dockerfile b/Dockerfile index 1f8cefb..1d48ace 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,18 +18,6 @@ RUN \ FROM base AS builder -# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数 -# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量 -ARG NEXT_PUBLIC_BACKEND_URL -ARG NEXT_PUBLIC_AGENT_URL -ARG NEXT_PUBLIC_AUDIO_SERVICE_URL -ARG NEXT_PUBLIC_MAP_URL -ARG NEXT_PUBLIC_MAP_WORKSPACE -ARG NEXT_PUBLIC_MAP_EXTENT -ARG NEXT_PUBLIC_NETWORK_NAME -ARG NEXT_PUBLIC_MAPBOX_TOKEN -ARG NEXT_PUBLIC_TIANDITU_TOKEN - COPY --from=deps /app/refine/node_modules ./node_modules COPY . . @@ -40,7 +28,9 @@ FROM base AS runner ENV NODE_ENV=production -COPY --from=builder /app/refine/public ./public +COPY --from=builder --chown=node:node /app/refine/public ./public +COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh RUN mkdir .next RUN chown node:node .next @@ -55,4 +45,5 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +ENTRYPOINT ["/entrypoint.sh"] CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index b8d860b..77938c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,19 +6,15 @@ services: build: context: . dockerfile: Dockerfile - args: - NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL} - NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL} - NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL} - NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL} - NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE} - NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT} - NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME} - NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN} - NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN} - env_file: - - .env environment: + BACKEND_URL: ${BACKEND_URL} + AGENT_URL: ${AGENT_URL} + MAP_URL: ${MAP_URL} + MAP_WORKSPACE: ${MAP_WORKSPACE} + MAP_EXTENT: ${MAP_EXTENT} + NETWORK_NAME: ${NETWORK_NAME} + MAPBOX_TOKEN: ${MAPBOX_TOKEN} + TIANDITU_TOKEN: ${TIANDITU_TOKEN} KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..5c04d60 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +node <<'NODE' +const fs = require("fs"); + +const parseExtent = (value) => { + if (!value) { + return [13508849, 3608036, 13555781, 3633813]; + } + + const extent = value.split(",").map(Number); + return extent.length === 4 && extent.every(Number.isFinite) + ? extent + : [13508849, 3608036, 13555781, 3633813]; +}; + +const config = { + BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000", + AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788", + MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver", + MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater", + MAP_EXTENT: parseExtent(process.env.MAP_EXTENT), + NETWORK_NAME: process.env.NETWORK_NAME || "tjwater", + MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "", + TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "", +}; + +fs.writeFileSync( + "/app/refine/public/runtime-config.js", + `window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`, +); +NODE + +exec "$@" diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 45ac202..194af83 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -9,16 +9,26 @@ type KeycloakTokenResponse = { refresh_token?: string; }; -const keycloakIssuer = process.env.KEYCLOAK_ISSUER!; -const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!; -const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!; -const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`; +const getKeycloakTokenEndpoint = () => { + const issuer = process.env.KEYCLOAK_ISSUER; + return issuer + ? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token` + : undefined; +}; const refreshAccessToken = async (token: JWT): Promise<JWT> => { if (!token.refreshToken) { return { ...token, error: "RefreshAccessTokenError" }; } + const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID; + const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET; + const keycloakTokenEndpoint = getKeycloakTokenEndpoint(); + + if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) { + return { ...token, error: "RefreshAccessTokenError" }; + } + const body = new URLSearchParams({ grant_type: "refresh_token", client_id: keycloakClientId, @@ -50,9 +60,9 @@ const authOptions: NextAuthOptions = { // Configure one or more authentication providers providers: [ KeycloakProvider({ - clientId: keycloakClientId, - clientSecret: keycloakClientSecret, - issuer: keycloakIssuer, + clientId: process.env.KEYCLOAK_CLIENT_ID ?? "", + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "", + issuer: process.env.KEYCLOAK_ISSUER ?? "", profile(profile) { return { id: profile.sub, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9cfb22c..280551d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; +import Script from "next/script"; import React, { Suspense } from "react"; import { RefineContext } from "./RefineContext"; import { META_DATA } from "@config/config"; @@ -18,6 +19,7 @@ export default async function RootLayout({ return ( <html lang="en"> <body> + <Script src="/runtime-config.js" strategy="beforeInteractive" /> <Suspense> <RefineContext defaultMode={defaultMode}>{children}</RefineContext> </Suspense> diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 4c40372..b6a20cd 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -27,6 +27,10 @@ import { GlobalChatbox } from "@components/chat/GlobalChatbox"; import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config"; import { useProjectStore } from "@/store/projectStore"; +const MAP_WORKSPACE_STORAGE_KEY = "MAP_WORKSPACE"; +const NETWORK_NAME_STORAGE_KEY = "NETWORK_NAME"; +const MAP_EXTENT_STORAGE_KEY = "MAP_EXTENT"; + type IUser = { id?: string; name?: string; @@ -70,9 +74,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ setMapWorkspace(workspace); setNetworkName(networkName); setMapExtent(extent); - localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", workspace); - localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", networkName); - localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); + localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace); + localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName); + localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(",")); localStorage.removeItem(`${workspace}_map_view`); setCurrentProjectId(projectId || networkName || workspace); setShowProjectSelector(false); diff --git a/src/config/config.ts b/src/config/config.ts index b2126e3..6e18479 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,13 +1,41 @@ +type RuntimeConfig = { + BACKEND_URL?: string; + AGENT_URL?: string; + MAP_URL?: string; + MAP_WORKSPACE?: string; + MAP_EXTENT?: number[] | string; + NETWORK_NAME?: string; + MAPBOX_TOKEN?: string; + TIANDITU_TOKEN?: string; +}; + +declare global { + interface Window { + __TJWATER_RUNTIME_CONFIG__?: RuntimeConfig; + } +} + +const runtimeConfig = + typeof window === "undefined" ? {} : window.__TJWATER_RUNTIME_CONFIG__ ?? {}; + +const parseMapExtent = (value: RuntimeConfig["MAP_EXTENT"]): number[] => { + if (Array.isArray(value)) { + return value; + } + + if (typeof value === "string" && value.trim()) { + return value.split(",").map(Number); + } + + return [13508849, 3608036, 13555781, 3633813]; +}; + export const config = { - BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000", - AGENT_URL: process.env.NEXT_PUBLIC_AGENT_URL || "http://127.0.0.1:8788", - AUDIO_SERVICE_URL: - process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083", - MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver", - MAP_WORKSPACE: process.env.NEXT_PUBLIC_MAP_WORKSPACE || "tjwater", - MAP_EXTENT: process.env.NEXT_PUBLIC_MAP_EXTENT - ? process.env.NEXT_PUBLIC_MAP_EXTENT.split(",").map(Number) - : [13508849, 3608036, 13555781, 3633813], + BACKEND_URL: runtimeConfig.BACKEND_URL || "http://127.0.0.1:8000", + AGENT_URL: runtimeConfig.AGENT_URL || "http://127.0.0.1:8788", + MAP_URL: runtimeConfig.MAP_URL || "http://127.0.0.1:8080/geoserver", + MAP_WORKSPACE: runtimeConfig.MAP_WORKSPACE || "tjwater", + MAP_EXTENT: parseMapExtent(runtimeConfig.MAP_EXTENT), MAP_DEFAULT_STYLE: { "stroke-width": 3, "stroke-color": "rgba(51, 153, 204, 0.9)", @@ -33,7 +61,7 @@ export const config = { "scada", ], }; -export let NETWORK_NAME = process.env.NEXT_PUBLIC_NETWORK_NAME || "tjwater"; +export let NETWORK_NAME = runtimeConfig.NETWORK_NAME || "tjwater"; export const setNetworkName = (name: string) => { NETWORK_NAME = name; @@ -47,11 +75,8 @@ export const setMapExtent = (extent: number[]) => { config.MAP_EXTENT = extent; }; -export const MAPBOX_TOKEN = - process.env.NEXT_PUBLIC_MAPBOX_TOKEN || - "pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"; -export const TIANDITU_TOKEN = - process.env.NEXT_PUBLIC_TIANDITU_TOKEN || "e3e8ad95ee911741fa71ed7bff2717ec"; +export const MAPBOX_TOKEN = runtimeConfig.MAPBOX_TOKEN || ""; +export const TIANDITU_TOKEN = runtimeConfig.TIANDITU_TOKEN || ""; export const PROJECT_TITLE = process.env.PROJECT_TITLE || "TJWater Project"; export const META_DATA = { title: PROJECT_TITLE, diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 4692e8d..8e768f9 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -13,6 +13,9 @@ interface ProjectContextType { } const ProjectContext = createContext<ProjectContextType | undefined>(undefined); +const MAP_WORKSPACE_STORAGE_KEY = "MAP_WORKSPACE"; +const NETWORK_NAME_STORAGE_KEY = "NETWORK_NAME"; +const MAP_EXTENT_STORAGE_KEY = "MAP_EXTENT"; export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ children, @@ -38,15 +41,15 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ setMapWorkspace(ws); setNetworkName(net); setMapExtent(extent); - localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", extent.join(",")); + localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(",")); // Reset extent cache localStorage.removeItem(`${ws}_map_view`); setCurrentProject({ workspace: ws, networkName: net, extent: extent }); setCurrentProjectId(resolvedProjectId); // Save to localStorage - localStorage.setItem("NEXT_PUBLIC_MAP_WORKSPACE", ws); - localStorage.setItem("NEXT_PUBLIC_NETWORK_NAME", net); + localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, ws); + localStorage.setItem(NETWORK_NAME_STORAGE_KEY, net); setIsConfigured(true); @@ -76,10 +79,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ // Update workspace if different if (data?.gs_workspace && data.gs_workspace !== ws) { setMapWorkspace(data.gs_workspace); - localStorage.setItem( - "NEXT_PUBLIC_MAP_WORKSPACE", - data.gs_workspace, - ); + localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace); setCurrentProject((prev) => ({ ...prev, workspace: data.gs_workspace, @@ -92,7 +92,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ : null; if (bbox && bbox.length === 4) { setMapExtent(bbox); - localStorage.setItem("NEXT_PUBLIC_MAP_EXTENT", bbox.join(",")); + localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(",")); localStorage.removeItem(`${ws}_map_view`); setCurrentProject((prev) => ({ ...prev, extent: bbox })); } @@ -104,9 +104,9 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ useEffect(() => { // Check localStorage - const savedWorkspace = localStorage.getItem("NEXT_PUBLIC_MAP_WORKSPACE"); - const savedNetwork = localStorage.getItem("NEXT_PUBLIC_NETWORK_NAME"); - const savedExtent = localStorage.getItem("NEXT_PUBLIC_MAP_EXTENT"); + const savedWorkspace = localStorage.getItem(MAP_WORKSPACE_STORAGE_KEY); + const savedNetwork = localStorage.getItem(NETWORK_NAME_STORAGE_KEY); + const savedExtent = localStorage.getItem(MAP_EXTENT_STORAGE_KEY); const savedProjectId = localStorage.getItem("active_project"); // If we have saved config, use it. -- 2.54.0 From 1993cadba8ba27c2bf4c9526eca0a98a690ccf5d Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 20 Jul 2026 12:09:24 +0800 Subject: [PATCH 239/281] chore(config): replace env file with example --- .env | 16 ---------------- .env.example | 14 ++++++++++++++ .gitignore | 6 ++++-- 3 files changed, 18 insertions(+), 18 deletions(-) delete mode 100644 .env create mode 100644 .env.example diff --git a/.env b/.env deleted file mode 100644 index 14d3546..0000000 --- a/.env +++ /dev/null @@ -1,16 +0,0 @@ -KEYCLOAK_CLIENT_ID="tjwater" -KEYCLOAK_CLIENT_SECRET="83h0n413hau9bldzWdEaq6xRfASv24s5" -KEYCLOAK_ISSUER="https://keycloak.waternetwork.cn/realms/tjwater" -NEXTAUTH_SECRET="eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiS" -NEXTAUTH_URL="https://demo.waternetwork.cn/" - -# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀 -NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn" -NEXT_PUBLIC_AGENT_URL="https://agent.waternetwork.cn" -NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn" -NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver" -NEXT_PUBLIC_MAP_WORKSPACE="tjwater" -NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25" -NEXT_PUBLIC_NETWORK_NAME="tjwater" -NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg" -NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a1b3c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +KEYCLOAK_CLIENT_ID="tjwater" +KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret" +KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater" +NEXTAUTH_SECRET="replace-with-nextauth-secret" +NEXTAUTH_URL="https://frontend.example.com/" + +BACKEND_URL="https://server.example.com" +AGENT_URL="https://agent.example.com" +MAP_URL="https://geoserver.example.com/geoserver" +MAP_WORKSPACE="tjwater" +MAP_EXTENT="13490131,3630016,13525879,3666968.25" +NETWORK_NAME="tjwater" +MAPBOX_TOKEN="replace-with-public-mapbox-token" +TIANDITU_TOKEN="replace-with-public-tianditu-token" diff --git a/.gitignore b/.gitignore index affc947..5bb1e16 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,9 @@ yarn-debug.log* yarn-error.log* # local env files -.env.local +.env +.env.* +!.env.example # vercel .vercel @@ -35,4 +37,4 @@ yarn-error.log* next-env.d.ts memery.md -docs/ \ No newline at end of file +docs/ -- 2.54.0 From 5332f8f0c58e8b2a4fa9c42152e3d0381c70d124 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Mon, 20 Jul 2026 13:12:19 +0800 Subject: [PATCH 240/281] fix(layout): prevent map page overflow --- .gitignore | 1 + package.json | 3 +- scripts/generate-runtime-config.mjs | 39 ++++++++++++++++++++++ src/app/(main)/layout.tsx | 9 +++-- src/components/olmap/core/MapComponent.tsx | 12 +++---- 5 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 scripts/generate-runtime-config.mjs diff --git a/.gitignore b/.gitignore index 5bb1e16..8dd8a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # misc .DS_Store *.pem +/public/runtime-config.js # debug npm-debug.log* diff --git a/package.json b/package.json index eeff8bf..1f2ba00 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "node": ">=20" }, "scripts": { - "dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev", + "dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev", + "runtime:config": "node scripts/generate-runtime-config.mjs", "build": "next build", "start": "next start", "lint": "eslint .", diff --git a/scripts/generate-runtime-config.mjs b/scripts/generate-runtime-config.mjs new file mode 100644 index 0000000..3e74911 --- /dev/null +++ b/scripts/generate-runtime-config.mjs @@ -0,0 +1,39 @@ +import nextEnv from "@next/env"; +import fs from "node:fs"; +import path from "node:path"; + +const projectDir = process.cwd(); +const { loadEnvConfig } = nextEnv; + +loadEnvConfig(projectDir, process.env.NODE_ENV !== "production"); + +const parseExtent = (value) => { + if (!value) { + return [13508849, 3608036, 13555781, 3633813]; + } + + const extent = value.split(",").map(Number); + return extent.length === 4 && extent.every(Number.isFinite) + ? extent + : [13508849, 3608036, 13555781, 3633813]; +}; + +const config = { + BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000", + AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788", + MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver", + MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater", + MAP_EXTENT: parseExtent(process.env.MAP_EXTENT), + NETWORK_NAME: process.env.NETWORK_NAME || "tjwater", + MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "", + TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "", +}; + +const outputPath = path.join(projectDir, "public", "runtime-config.js"); + +fs.writeFileSync( + outputPath, + `window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`, +); + +console.log(`Generated ${path.relative(projectDir, outputPath)}`); diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 07f97ed..ed82d39 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -38,10 +38,15 @@ export default async function MainLayout({ Title={Title} Sider={AppSider} childrenBoxProps={{ - sx: { height: "100vh", p: 0 }, + sx: { + flex: 1, + minHeight: 0, + overflow: "auto", + p: 0, + }, }} containerBoxProps={{ - sx: { height: "100%" }, + sx: { height: "100vh", overflow: "hidden" }, }} > <Suspense fallback={<MapSkeleton />}> diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 0440c54..9be03b9 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -1138,12 +1138,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { }} > <MapContext.Provider value={map}> - <div className="relative w-full h-full"> - <div className="flex w-full h-full"> + <div className="relative h-full w-full overflow-hidden"> + <div className="flex h-full min-h-0 w-full min-w-0 overflow-hidden"> <div - className={`relative h-full ${isCompareMode ? "w-1/2" : "w-full"}`} + className={`relative h-full min-w-0 overflow-hidden ${isCompareMode ? "w-1/2" : "w-full"}`} > - <div ref={mapRef} className="w-full h-full"></div> + <div ref={mapRef} className="h-full w-full"></div> <canvas ref={canvasRef} className="pointer-events-none absolute inset-0" @@ -1155,8 +1155,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { )} </div> {isCompareMode && ( - <div className="relative h-full w-1/2 border-l border-white/40"> - <div ref={compareMapRef} className="w-full h-full"></div> + <div className="relative h-full min-w-0 w-1/2 overflow-hidden border-l border-white/40"> + <div ref={compareMapRef} className="h-full w-full"></div> <canvas ref={compareCanvasRef} className="pointer-events-none absolute inset-0" -- 2.54.0 From 4596af7c1259484696d156eacdcf13968e0a88d4 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Wed, 22 Jul 2026 11:26:06 +0800 Subject: [PATCH 241/281] =?UTF-8?q?docs:=20=E7=BC=96=E5=86=99=E4=B8=AD?= =?UTF-8?q?=E6=96=87=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.MD | 101 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/README.MD b/README.MD index c36023c..f938430 100644 --- a/README.MD +++ b/README.MD @@ -1,48 +1,91 @@ -# my-refine-app +# TJWaterFrontend_Refine 内部前端 -<div align="center" style="margin: 30px;"> - <a href="https://refine.dev"> - <img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png"> - </a> -</div> -<br/> +`TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。 -This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app). +## 技术栈 -## Getting Started +- Next.js 16 +- React 19 +- Refine 5 +- MUI 6 / MUI X +- OpenLayers、deck.gl、Turf +- Zustand、NextAuth、Jest -A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility ✨ +## 目录结构 -Refine's hooks and components simplifies the development process and eliminates the repetitive tasks by providing industry-standard solutions for crucial aspects of a project, including authentication, access control, routing, networking, state management, and i18n. - -## Available Scripts - -### Running the development server. - -```bash - npm run dev +```text +src/app/ Next.js App Router 页面 +src/components/ 复用 UI 组件 +src/providers/ Refine、认证、数据和主题 provider +src/hooks/ 业务 hooks +src/utils/ 通用工具 +public/ 静态资源 +scripts/ 运行时配置和辅助脚本 +Dockerfile 镜像构建文件 +docker-compose.yml 本地编排参考 ``` -### Building for production. +新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。 + +## 本地开发 + +要求 Node.js 20 或更高版本: ```bash - npm run build +npm install +npm run dev ``` -### Running the production server. +`npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。 + +## 常用命令 ```bash - npm run start +npm run lint +npm test +npm run test:coverage +npm run build +npm run start +docker build -t tjwater-frontend:local . ``` -## Learn More +- `npm run lint`:运行 ESLint。 +- `npm test`:运行 Jest。 +- `npm run test:coverage`:生成测试覆盖率。 +- `npm run build`:生成生产构建。 +- `npm run start`:启动生产模式服务。 -To learn more about **Refine**, please check out the [Documentation](https://refine.dev/docs) +## 配置说明 -- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview) -- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/) -- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/) +运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。 -## License +只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。 -MIT +## 开发规范 + +- React 组件文件使用 `PascalCase.tsx`。 +- 普通 TypeScript 模块、hooks、store、provider 和工具使用 `camelCase.ts`。 +- `src/app` 路由目录使用 `kebab-case`,保留 Next.js 路由组和动态段语法。 +- UI 优先沿用 MUI、Refine 和既有地图/聊天界面模式。 +- 与后端或 Agent 通信的字段保持接口原始格式,通常为 `snake_case`。 + +## 测试与发布 + +提交前建议运行: + +```bash +npm run lint +npm test +``` + +发布镜像前运行: + +```bash +npm run build +``` + +Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。 + +## 安全规则 + +不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。 -- 2.54.0 From 7745333a582381443d2dfa9147fc775e17acf0c0 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 11:01:45 +0800 Subject: [PATCH 242/281] fix(frontend): query schemes by type --- .../olmap/BurstDetection/SchemeQuery.tsx | 11 +++++++---- .../olmap/BurstLocation/AnalysisParameters.tsx | 2 +- .../olmap/BurstLocation/SchemeQuery.tsx | 16 ++++++++-------- .../olmap/BurstSimulation/SchemeQuery.tsx | 15 +++++++++++---- .../ContaminantSimulation/SchemeQuery.tsx | 15 +++++++++++---- .../olmap/DMALeakDetection/SchemeQuery.tsx | 18 +++++++++++++----- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 15 +++++++++++---- 7 files changed, 62 insertions(+), 30 deletions(-) diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx index 3c696c0..4385e8c 100644 --- a/src/components/olmap/BurstDetection/SchemeQuery.tsx +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -127,12 +127,15 @@ const SchemeQuery: React.FC<Props> = ({ const handleQuery = async () => { setLoading(true); try { - const params: Record<string, string> = { network: NETWORK_NAME }; + const params: Record<string, string> = { + network: NETWORK_NAME, + scheme_type: "burst_detection", + }; if (!queryAll && queryDate) { params.query_date = queryDate.startOf("day").toISOString(); } - const response = await api.get("/api/v1/burst-detection/schemes/", { params }); + const response = await api.get("/api/v1/schemes", { params }); const nextSchemes = response.data as BurstDetectionSchemeRecord[]; setSchemes(nextSchemes); open?.({ @@ -154,8 +157,8 @@ const SchemeQuery: React.FC<Props> = ({ const handleViewSchemeResult = async (schemeName: string) => { try { const response = await api.get( - `/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`, - { params: { network: NETWORK_NAME } }, + `/api/v1/schemes/${encodeURIComponent(schemeName)}`, + { params: { network: NETWORK_NAME, scheme_type: "burst_detection" } }, ); const schemeRecord = response.data as BurstDetectionSchemeRecord & { result_payload?: BurstDetectionResult; diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index eefd57d..0ef27b3 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -125,7 +125,7 @@ const AnalysisParameters: React.FC<Props> = ({ setSchemeLoading(true); try { const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { - params: { network: NETWORK_NAME }, + params: { network: NETWORK_NAME, scheme_type: "burst_analysis" }, }); const burstSchemes = (response.data as SchemeItem[]).filter( (scheme) => scheme.scheme_type === "burst_analysis", diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index ef0cd8b..a001daa 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -222,18 +222,18 @@ const SchemeQuery: React.FC<Props> = ({ const handleQuery = async () => { setLoading(true); try { - // API call to fetch schemes - // Adjust URL as needed - let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`; - const params: Record<string, string> = { network: NETWORK_NAME }; + const params: Record<string, string> = { + network: NETWORK_NAME, + scheme_type: "burst_location", + }; if (!queryAll && queryDate) { params.query_date = queryDate.startOf("day").toISOString(); } const [response, simulationResponse] = await Promise.all([ - api.get(url, { params }), + api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }), api.get(`${config.BACKEND_URL}/api/v1/schemes`, { - params: { network: NETWORK_NAME }, + params: { network: NETWORK_NAME, scheme_type: "burst_analysis" }, }), ]); const nextSchemes = response.data as BurstSchemeRecord[]; @@ -274,8 +274,8 @@ const SchemeQuery: React.FC<Props> = ({ const handleViewSchemeResult = async (schemeName: string) => { try { const response = await api.get( - `${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`, - { params: { network: NETWORK_NAME } }, + `${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`, + { params: { network: NETWORK_NAME, scheme_type: "burst_location" } }, ); const schemeRecord = response.data as BurstSchemeRecord & { result_payload?: BurstLocationResult; diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index c05b8c5..fa38b0e 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -152,9 +152,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { - const response = await api.get( - `${config.BACKEND_URL}/api/v1/schemes?network=${network}`, - ); + const params: Record<string, string> = { + network, + scheme_type: SCHEME_TYPE, + }; + if (!queryAll && queryDate) { + params.query_date = queryDate.startOf("day").toISOString(); + } + const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { + params, + }); let filteredResults = response.data; if (!queryAll) { @@ -178,7 +185,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ if (filteredResults.length === 0) { open?.({ - type: "error", + type: "success", message: "查询结果", description: queryAll ? "没有找到任何方案" diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index 9fb159d..c9607f4 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -216,9 +216,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ if (!queryAll && !queryDate) return; setLoading(true); try { - const response = await api.get( - `${config.BACKEND_URL}/api/v1/schemes?network=${network}`, - ); + const params: Record<string, string> = { + network, + scheme_type: SCHEME_TYPE, + }; + if (!queryAll && queryDate) { + params.query_date = queryDate.startOf("day").toISOString(); + } + const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { + params, + }); let filteredResults = response.data; if (!queryAll) { @@ -244,7 +251,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ if (filteredResults.length === 0) { open?.({ - type: "error", + type: "success", message: "查询结果", description: queryAll ? "没有找到任何方案" diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index 3895d01..c92c110 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -79,18 +79,21 @@ const SchemeQuery: React.FC<Props> = ({ const handleQuery = async () => { setLoading(true); try { - const params: Record<string, string> = { network: NETWORK_NAME }; + const params: Record<string, string> = { + network: NETWORK_NAME, + scheme_type: "dma_leak_identification", + }; if (!queryAll && queryDate) { params.query_date = queryDate.startOf("day").toISOString(); } - const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, { + const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params, }); const nextSchemes = response.data as LeakageSchemeRecord[]; setSchemes(nextSchemes); if (nextSchemes.length === 0) { open?.({ - type: "error", + type: "success", message: "查询结果", description: queryAll ? "没有找到任何方案" @@ -117,8 +120,13 @@ const SchemeQuery: React.FC<Props> = ({ const handleViewSchemeResult = async (schemeName: string) => { try { const response = await api.get( - `${config.BACKEND_URL}/api/v1/leakage/schemes/${encodeURIComponent(schemeName)}`, - { params: { network: NETWORK_NAME } }, + `${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`, + { + params: { + network: NETWORK_NAME, + scheme_type: "dma_leak_identification", + }, + }, ); onViewResult(response.data as LeakageResultDetail); } catch (error: any) { diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index be64f71..fdd0203 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -262,9 +262,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ setLoading(true); try { - const response = await api.get( - `${config.BACKEND_URL}/api/v1/schemes?network=${network}`, - ); + const params: Record<string, string> = { + network, + scheme_type: SCHEME_TYPE, + }; + if (!queryAll && queryDate) { + params.query_date = queryDate.startOf("day").toISOString(); + } + const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, { + params, + }); let filteredResults = response.data; @@ -292,7 +299,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ if (filteredResults.length === 0) { open?.({ - type: "error", + type: "success", message: "未找到相关方案", description: "请尝试更改查询条件", }); -- 2.54.0 From a3331691de3dbe468d36fec873e438884a0e183d Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 11:04:15 +0800 Subject: [PATCH 243/281] test(frontend): strengthen default time assertion --- src/components/olmap/BurstDetection/AnalysisParameters.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.test.ts b/src/components/olmap/BurstDetection/AnalysisParameters.test.ts index 46be13f..fa083a5 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.test.ts +++ b/src/components/olmap/BurstDetection/AnalysisParameters.test.ts @@ -17,7 +17,8 @@ describe("burst detection request", () => { sampling_interval_minutes: 15, }); expect(state.detectionMode).toBe("latest"); - expect(state.targetTime?.minute() % 15).toBe(0); + expect(state.targetTime).not.toBeNull(); + expect(state.targetTime!.minute() % 15).toBe(0); }); it("sends one target time for historical replay", () => { -- 2.54.0 From 5894ee277a66ec187c243d135ccc64e6489fd67a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 11:04:31 +0800 Subject: [PATCH 244/281] fix(frontend): use success style for empty scheme queries --- .../olmap/MonitoringPlaceOptimization/SchemeQuery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 4b9db6f..58ba297 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -208,7 +208,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ if (filteredResults.length === 0) { open?.({ - type: "error", + type: "success", message: "查询结果", description: queryAll ? "没有找到任何方案" -- 2.54.0 From 82e75c03d09035a823503a94ed21439f48db3c20 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 11:52:43 +0800 Subject: [PATCH 245/281] feat(health-risk): add printable assessment report --- .../HealthRiskAnalysis/HealthRiskContext.tsx | 5 + .../HealthRiskReport.test.tsx | 84 ++ .../HealthRiskAnalysis/HealthRiskReport.tsx | 746 ++++++++++++++++++ .../HealthRiskStatistics.tsx | 61 +- .../olmap/HealthRiskAnalysis/Timeline.tsx | 8 +- .../healthRiskReport.test.ts | 84 ++ .../HealthRiskAnalysis/healthRiskReport.ts | 201 +++++ .../olmap/HealthRiskAnalysis/types.ts | 6 +- 8 files changed, 1189 insertions(+), 6 deletions(-) create mode 100644 src/components/olmap/HealthRiskAnalysis/HealthRiskReport.test.tsx create mode 100644 src/components/olmap/HealthRiskAnalysis/HealthRiskReport.tsx create mode 100644 src/components/olmap/HealthRiskAnalysis/healthRiskReport.test.ts create mode 100644 src/components/olmap/HealthRiskAnalysis/healthRiskReport.ts diff --git a/src/components/olmap/HealthRiskAnalysis/HealthRiskContext.tsx b/src/components/olmap/HealthRiskAnalysis/HealthRiskContext.tsx index c44d021..71c2e3b 100644 --- a/src/components/olmap/HealthRiskAnalysis/HealthRiskContext.tsx +++ b/src/components/olmap/HealthRiskAnalysis/HealthRiskContext.tsx @@ -8,6 +8,8 @@ interface HealthRiskContextType { setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>; currentYear: number; setCurrentYear: Dispatch<SetStateAction<number>>; + analysisQueryTime: string | null; + setAnalysisQueryTime: Dispatch<SetStateAction<string | null>>; } const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined); @@ -15,6 +17,7 @@ const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undef export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]); const [currentYear, setCurrentYear] = useState<number>(4); + const [analysisQueryTime, setAnalysisQueryTime] = useState<string | null>(null); return ( <HealthRiskContext.Provider @@ -23,6 +26,8 @@ export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children setPredictionResults, currentYear, setCurrentYear, + analysisQueryTime, + setAnalysisQueryTime, }} > {children} diff --git a/src/components/olmap/HealthRiskAnalysis/HealthRiskReport.test.tsx b/src/components/olmap/HealthRiskAnalysis/HealthRiskReport.test.tsx new file mode 100644 index 0000000..dc77565 --- /dev/null +++ b/src/components/olmap/HealthRiskAnalysis/HealthRiskReport.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import HealthRiskReport from "./HealthRiskReport"; +import { PredictionResult } from "./types"; + +jest.mock("echarts-for-react", () => ({ + __esModule: true, + default: () => <div data-testid="report-chart" />, +})); + +const predictionResults: PredictionResult[] = [ + { + link_id: "PIPE-007", + survival_function: { + x: [4, 5], + y: [0.2, 0.15], + a: 0, + b: 0, + }, + }, + { + link_id: "PIPE-021", + survival_function: { + x: [4, 5], + y: [0.8, 0.7], + a: 0, + b: 0, + }, + }, +]; + +describe("HealthRiskReport", () => { + it("renders automatic report identity and priority results", () => { + render( + <HealthRiskReport + open + onClose={jest.fn()} + predictionResults={predictionResults} + currentYear={4} + analysisQueryTime="2026-07-30T01:02:03.000Z" + networkName="fengyang" + generatedAt={new Date("2026-07-30T03:04:05.000Z")} + />, + ); + + expect( + screen.getByRole("heading", { name: "管网健康风险体检报告" }), + ).toBeInTheDocument(); + expect(screen.getByText("fengyang")).toBeInTheDocument(); + expect(screen.getByText("第 4 年")).toBeInTheDocument(); + expect(screen.getByText("PIPE-007")).toBeInTheDocument(); + expect(screen.getAllByTestId("report-chart")).toHaveLength(2); + }); + + it("prints the report and restores document state after printing", () => { + const originalTitle = document.title; + const print = jest + .spyOn(window, "print") + .mockImplementation(() => undefined); + + render( + <HealthRiskReport + open + onClose={jest.fn()} + predictionResults={predictionResults} + currentYear={4} + analysisQueryTime="2026-07-30T01:02:03.000Z" + networkName="fengyang" + generatedAt={new Date("2026-07-30T03:04:05.000Z")} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "打印" })); + + expect(print).toHaveBeenCalledTimes(1); + expect(document.body).toHaveClass("health-risk-report-printing"); + expect(document.title).toContain("管网健康风险体检报告-fengyang"); + + window.dispatchEvent(new Event("afterprint")); + + expect(document.body).not.toHaveClass("health-risk-report-printing"); + expect(document.title).toBe(originalTitle); + print.mockRestore(); + }); +}); diff --git a/src/components/olmap/HealthRiskAnalysis/HealthRiskReport.tsx b/src/components/olmap/HealthRiskAnalysis/HealthRiskReport.tsx new file mode 100644 index 0000000..0cddbaf --- /dev/null +++ b/src/components/olmap/HealthRiskAnalysis/HealthRiskReport.tsx @@ -0,0 +1,746 @@ +"use client"; + +import React, { useEffect, useMemo, useRef } from "react"; +import ReactECharts from "echarts-for-react"; +import { + Box, + Button, + Dialog, + GlobalStyles, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { + Close, + PrintOutlined, + WaterDropOutlined, +} from "@mui/icons-material"; +import { PredictionResult } from "./types"; +import { buildHealthRiskReportData } from "./healthRiskReport"; + +interface HealthRiskReportProps { + open: boolean; + onClose: () => void; + predictionResults: PredictionResult[]; + currentYear: number; + analysisQueryTime: string | null; + networkName: string; + generatedAt: Date; +} + +const REPORT_BLUE = "#0b4f87"; +const REPORT_INK = "#172435"; +const REPORT_MUTED = "#5f6f80"; +const REPORT_LINE = "#d8e0e8"; + +const reportFontFamily = + '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'; + +const formatDateTime = (value: Date | string | null) => { + if (!value) return "未记录"; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return "未记录"; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(date); +}; + +const formatPercent = (value: number | null, fractionDigits = 1) => + value === null ? "无有效数据" : `${(value * 100).toFixed(fractionDigits)}%`; + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( + <Stack + direction="row" + alignItems="center" + spacing={1.25} + sx={{ mb: 1.5 }} + > + <Box + aria-hidden="true" + sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }} + /> + <Typography + component="h2" + sx={{ + color: REPORT_INK, + fontSize: 17, + fontWeight: 700, + lineHeight: 1.4, + }} + > + {children} + </Typography> + </Stack> +); + +const HealthRiskReport: React.FC<HealthRiskReportProps> = ({ + open, + onClose, + predictionResults, + currentYear, + analysisQueryTime, + networkName, + generatedAt, +}) => { + const printStateRef = useRef<{ title: string } | null>(null); + const reportData = useMemo( + () => buildHealthRiskReportData(predictionResults, currentYear), + [currentYear, predictionResults], + ); + + useEffect( + () => () => { + if (printStateRef.current) { + document.title = printStateRef.current.title; + document.body.classList.remove("health-risk-report-printing"); + } + }, + [], + ); + + const restoreAfterPrint = () => { + if (!printStateRef.current) return; + document.title = printStateRef.current.title; + printStateRef.current = null; + document.body.classList.remove("health-risk-report-printing"); + }; + + const handlePrint = () => { + if (printStateRef.current) return; + printStateRef.current = { title: document.title }; + document.title = `管网健康风险体检报告-${networkName || "未命名管网"}-${new Intl.DateTimeFormat( + "zh-CN", + { year: "numeric", month: "2-digit", day: "2-digit" }, + ) + .format(generatedAt) + .replaceAll("/", "-")}`; + document.body.classList.add("health-risk-report-printing"); + window.addEventListener("afterprint", restoreAfterPrint, { once: true }); + window.print(); + }; + + const distributionOption = { + animation: false, + grid: { top: 4, right: 38, bottom: 28, left: 148 }, + xAxis: { + type: "value", + name: "管段数", + minInterval: 1, + axisLine: { lineStyle: { color: "#8b99a8" } }, + axisLabel: { color: REPORT_MUTED, fontSize: 10 }, + splitLine: { lineStyle: { color: "#e7edf2" } }, + nameTextStyle: { color: REPORT_MUTED, fontSize: 10 }, + }, + yAxis: { + type: "category", + inverse: true, + data: reportData.distribution.map((item) => item.label), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: REPORT_INK, fontSize: 10 }, + }, + tooltip: { show: false }, + series: [ + { + type: "bar", + barMaxWidth: 14, + data: reportData.distribution.map((item) => ({ + value: item.count, + itemStyle: { color: item.color }, + })), + label: { + show: true, + position: "right", + color: REPORT_INK, + fontSize: 10, + }, + }, + ], + }; + + const trendOption = { + animation: false, + grid: { top: 24, right: 24, bottom: 38, left: 48 }, + xAxis: { + type: "category", + name: "预测年份", + boundaryGap: false, + data: reportData.trend.map((item) => item.year), + axisLine: { lineStyle: { color: "#8b99a8" } }, + axisLabel: { + color: REPORT_MUTED, + fontSize: 10, + interval: Math.max(0, Math.ceil(reportData.trend.length / 9) - 1), + }, + nameTextStyle: { color: REPORT_MUTED, fontSize: 10 }, + }, + yAxis: { + type: "value", + name: "较高及以上风险管段数", + minInterval: 1, + axisLine: { show: false }, + axisLabel: { color: REPORT_MUTED, fontSize: 10 }, + splitLine: { lineStyle: { color: "#e7edf2" } }, + nameTextStyle: { color: REPORT_MUTED, fontSize: 10 }, + }, + tooltip: { show: false }, + series: [ + { + type: "line", + smooth: true, + symbol: "none", + lineStyle: { color: "#c83d3d", width: 2.5 }, + areaStyle: { color: "rgba(200, 61, 61, 0.08)" }, + data: reportData.trend.map((item) => item.count), + }, + ], + }; + + const metricItems = [ + { label: "分析管段", value: reportData.totalCount.toLocaleString("zh-CN") }, + { label: "有效结果", value: reportData.validCount.toLocaleString("zh-CN") }, + { + label: "平均生存概率", + value: formatPercent(reportData.averageProbability), + }, + { + label: "较高及以上风险", + value: `${reportData.highRiskCount.toLocaleString("zh-CN")} 条`, + detail: formatPercent(reportData.highRiskRatio), + danger: reportData.highRiskCount > 0, + }, + ]; + + return ( + <> + <GlobalStyles + styles={{ + "@page": { size: "A4 portrait", margin: 0 }, + "@media print": { + "html, body": { + width: "210mm", + minHeight: "297mm", + margin: 0, + padding: 0, + backgroundColor: "#fff", + }, + "body.health-risk-report-printing > *:not(.health-risk-report-dialog)": + { + display: "none !important", + }, + ".health-risk-report-dialog": { + position: "static !important", + display: "block !important", + }, + ".health-risk-report-dialog .MuiBackdrop-root, .health-risk-report-actions": + { + display: "none !important", + }, + ".health-risk-report-dialog .MuiDialog-container": { + display: "block !important", + height: "auto !important", + }, + ".health-risk-report-dialog .MuiDialog-paper": { + width: "210mm !important", + maxWidth: "none !important", + minHeight: "297mm !important", + maxHeight: "none !important", + margin: "0 !important", + overflow: "visible !important", + boxShadow: "none !important", + }, + ".health-risk-report-preview": { + padding: "0 !important", + overflow: "visible !important", + backgroundColor: "#fff !important", + }, + "#health-risk-print-root": { + width: "210mm !important", + minHeight: "297mm !important", + padding: "12mm !important", + boxShadow: "none !important", + printColorAdjust: "exact", + WebkitPrintColorAdjust: "exact", + }, + ".health-risk-report-section, .health-risk-report-metrics": { + breakInside: "avoid", + pageBreakInside: "avoid", + }, + ".health-risk-report-table-row": { + breakInside: "avoid", + pageBreakInside: "avoid", + }, + }, + }} + /> + <Dialog + open={open} + onClose={onClose} + maxWidth={false} + fullWidth + className="health-risk-report-dialog" + slotProps={{ + paper: { + sx: { + width: "100%", + height: "100%", + maxHeight: "100%", + m: 0, + borderRadius: 0, + bgcolor: "#e8edf2", + }, + }, + }} + > + <Stack + className="health-risk-report-actions" + direction="row" + alignItems="center" + justifyContent="space-between" + sx={{ + position: "sticky", + top: 0, + zIndex: 2, + minHeight: 64, + px: { xs: 1.5, sm: 3 }, + bgcolor: "#fff", + boxShadow: "0 1px 4px rgba(23, 36, 53, 0.16)", + }} + > + <Stack direction="row" alignItems="center" spacing={1}> + <IconButton + onClick={onClose} + aria-label="关闭报告预览" + sx={{ width: 40, height: 40 }} + > + <Close /> + </IconButton> + <Box> + <Typography sx={{ fontWeight: 700, color: REPORT_INK }}> + 报告预览 + </Typography> + <Typography variant="caption" sx={{ color: REPORT_MUTED }}> + A4 纵向,可在打印窗口另存为 PDF + </Typography> + </Box> + </Stack> + <Button + variant="contained" + startIcon={<PrintOutlined />} + onClick={handlePrint} + sx={{ + minHeight: 40, + borderRadius: 1.5, + bgcolor: REPORT_BLUE, + "&:hover": { bgcolor: "#083f6d" }, + "&:active": { transform: "scale(0.96)" }, + }} + > + 打印 + </Button> + </Stack> + + <Box + className="health-risk-report-preview" + sx={{ + flex: 1, + overflow: "auto", + px: { xs: 0, sm: 2 }, + py: { xs: 0, sm: 3 }, + }} + > + <Box + id="health-risk-print-root" + lang="zh-CN" + sx={{ + boxSizing: "border-box", + width: { xs: "100%", sm: "min(210mm, 100%)" }, + minHeight: "297mm", + mx: "auto", + p: { xs: 2, sm: "12mm" }, + bgcolor: "#fff", + color: REPORT_INK, + boxShadow: { xs: "none", sm: "0 12px 32px rgba(23,36,53,0.16)" }, + fontFamily: reportFontFamily, + fontVariantNumeric: "tabular-nums", + }} + > + <Box + component="header" + sx={{ + bgcolor: REPORT_BLUE, + color: "#f4f8fb", + px: { xs: 2.5, sm: 4 }, + py: { xs: 2.5, sm: 3.5 }, + }} + > + <Stack + direction={{ xs: "column", sm: "row" }} + justifyContent="space-between" + alignItems={{ xs: "flex-start", sm: "center" }} + spacing={2} + > + <Box> + <Stack direction="row" alignItems="center" spacing={1}> + <WaterDropOutlined aria-hidden="true" /> + <Typography + lang="en" + sx={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.08em" }} + > + TJWATER + </Typography> + </Stack> + <Typography + component="h1" + sx={{ + mt: 1.5, + fontSize: { xs: 24, sm: 30 }, + fontWeight: 700, + lineHeight: 1.25, + textWrap: "balance", + }} + > + 管网健康风险体检报告 + </Typography> + <Typography + sx={{ + mt: 0.75, + color: "#cfe1ef", + fontSize: 13, + lineHeight: 1.7, + }} + > + Pipeline health risk assessment + </Typography> + </Box> + <Box + sx={{ + minWidth: { sm: 168 }, + width: { xs: "100%", sm: "auto" }, + pt: { xs: 2, sm: 0 }, + pl: { xs: 0, sm: 3 }, + borderTop: { + xs: "1px solid rgba(224, 238, 248, 0.28)", + sm: "none", + }, + borderLeft: { + xs: "none", + sm: "1px solid rgba(224, 238, 248, 0.28)", + }, + }} + > + <Stack direction="row" alignItems="center" spacing={0.8}> + <Box + aria-hidden="true" + sx={{ + width: 7, + height: 7, + flexShrink: 0, + borderRadius: "50%", + bgcolor: + reportData.highRiskCount > 0 ? "#ffb3a7" : "#9dd7bd", + }} + /> + <Typography sx={{ color: "#cfe1ef", fontSize: 11 }}> + 本期提示 + </Typography> + </Stack> + <Typography + sx={{ + mt: 0.75, + fontWeight: 700, + fontSize: 20, + lineHeight: 1.35, + }} + > + {reportData.highRiskCount > 0 ? "需重点关注" : "持续监测"} + </Typography> + </Box> + </Stack> + </Box> + + <Box + component="dl" + sx={{ + m: 0, + display: "grid", + gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" }, + borderBottom: `1px solid ${REPORT_LINE}`, + }} + > + {[ + ["管网项目", networkName || "未命名管网"], + ["预测时点", `第 ${currentYear} 年`], + ["分析数据时间", formatDateTime(analysisQueryTime)], + ["报告生成时间", formatDateTime(generatedAt)], + ].map(([label, value]) => ( + <Box + key={label} + sx={{ + display: "grid", + gridTemplateColumns: "96px 1fr", + gap: 1, + px: 2, + py: 1.25, + borderBottom: { xs: `1px solid ${REPORT_LINE}`, sm: "none" }, + }} + > + <Typography + component="dt" + sx={{ color: REPORT_MUTED, fontSize: 12 }} + > + {label} + </Typography> + <Typography + component="dd" + sx={{ + m: 0, + color: REPORT_INK, + fontSize: 12, + fontWeight: 600, + overflowWrap: "anywhere", + }} + > + {value} + </Typography> + </Box> + ))} + </Box> + + <Box + component="section" + className="health-risk-report-section" + sx={{ mt: 3 }} + > + <SectionTitle>总体结论</SectionTitle> + <Box + sx={{ + bgcolor: + reportData.highRiskCount > 0 ? "#fff4f1" : "#eff8f4", + px: 2.25, + py: 1.75, + }} + > + <Typography sx={{ fontSize: 13, lineHeight: 1.8, color: REPORT_INK }}> + {reportData.conclusion} + </Typography> + {reportData.invalidCount > 0 && ( + <Typography sx={{ mt: 0.5, fontSize: 11, color: REPORT_MUTED }}> + 另有 {reportData.invalidCount} 条管段在当前预测年份缺少有效结果,未纳入本期统计。 + </Typography> + )} + </Box> + </Box> + + <Box + className="health-risk-report-metrics" + sx={{ + mt: 2.25, + display: "grid", + gridTemplateColumns: { xs: "repeat(2, 1fr)", sm: "repeat(4, 1fr)" }, + borderTop: `1px solid ${REPORT_LINE}`, + borderBottom: `1px solid ${REPORT_LINE}`, + }} + > + {metricItems.map((item, index) => ( + <Box + key={item.label} + sx={{ + px: 1.5, + py: 1.75, + borderRight: + index < metricItems.length - 1 + ? `1px solid ${REPORT_LINE}` + : "none", + }} + > + <Typography sx={{ color: REPORT_MUTED, fontSize: 11 }}> + {item.label} + </Typography> + <Typography + sx={{ + mt: 0.5, + color: item.danger ? "#a62f2f" : REPORT_INK, + fontSize: 20, + fontWeight: 700, + lineHeight: 1.25, + }} + > + {item.value} + </Typography> + {item.detail && ( + <Typography sx={{ mt: 0.25, color: REPORT_MUTED, fontSize: 10 }}> + 占有效结果 {item.detail} + </Typography> + )} + </Box> + ))} + </Box> + + <Box + component="section" + className="health-risk-report-section" + sx={{ mt: 3 }} + > + <SectionTitle>当前年份风险分布</SectionTitle> + <Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}> + 生存概率越低,表示模型预测风险越高。统计口径与地图风险图例一致。 + </Typography> + <ReactECharts + option={distributionOption} + style={{ width: "100%", height: 300 }} + opts={{ renderer: "svg" }} + notMerge + /> + </Box> + + <Box + component="section" + className="health-risk-report-section" + sx={{ mt: 3 }} + > + <SectionTitle>重点风险趋势</SectionTitle> + <Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}> + 展示各预测年份中生存概率不高于 0.3 的管段数量。 + </Typography> + <ReactECharts + option={trendOption} + style={{ width: "100%", height: 240 }} + opts={{ renderer: "svg" }} + notMerge + /> + </Box> + + <Box component="section" sx={{ mt: 3 }}> + <SectionTitle>重点风险管段</SectionTitle> + <Typography sx={{ mb: 1.25, color: REPORT_MUTED, fontSize: 11 }}> + 按当前预测年份的生存概率从低到高排列,最多展示 20 条。 + </Typography> + {reportData.priorityPipes.length > 0 ? ( + <TableContainer> + <Table + size="small" + aria-label="重点风险管段" + sx={{ tableLayout: "fixed" }} + > + <TableHead> + <TableRow sx={{ bgcolor: "#edf3f7" }}> + <TableCell sx={{ width: 52, fontWeight: 700 }}>序号</TableCell> + <TableCell sx={{ width: "34%", fontWeight: 700 }}> + 管段编号 + </TableCell> + <TableCell align="right" sx={{ width: 110, fontWeight: 700 }}> + 生存概率 + </TableCell> + <TableCell sx={{ fontWeight: 700 }}>风险等级</TableCell> + </TableRow> + </TableHead> + <TableBody> + {reportData.priorityPipes.map((pipe, index) => ( + <TableRow + key={`${pipe.linkId}-${index}`} + className="health-risk-report-table-row" + > + <TableCell>{index + 1}</TableCell> + <TableCell + sx={{ fontWeight: 600, overflowWrap: "anywhere" }} + > + {pipe.linkId} + </TableCell> + <TableCell align="right"> + {formatPercent(pipe.probability, 2)} + </TableCell> + <TableCell> + <Stack direction="row" alignItems="center" spacing={1}> + <Box + aria-hidden="true" + sx={{ + width: 9, + height: 9, + flexShrink: 0, + bgcolor: pipe.color, + borderRadius: "50%", + }} + /> + <Typography sx={{ fontSize: 12 }}> + {pipe.riskLabel} + </Typography> + </Stack> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </TableContainer> + ) : ( + <Box sx={{ bgcolor: "#f4f7f9", px: 2, py: 2 }}> + <Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}> + 当前预测年份没有较高及以上风险管段。 + </Typography> + </Box> + )} + </Box> + + <Box + component="section" + className="health-risk-report-section" + sx={{ mt: 3 }} + > + <SectionTitle>处置建议</SectionTitle> + <Box component="ol" sx={{ m: 0, pl: 3 }}> + {reportData.recommendations.map((recommendation) => ( + <Typography + component="li" + key={recommendation} + sx={{ + pl: 0.5, + mb: 0.75, + color: REPORT_INK, + fontSize: 12, + lineHeight: 1.75, + }} + > + {recommendation} + </Typography> + ))} + </Box> + </Box> + + <Box + component="footer" + sx={{ + mt: 3.5, + pt: 1.5, + borderTop: `1px solid ${REPORT_LINE}`, + display: "flex", + justifyContent: "space-between", + gap: 2, + color: REPORT_MUTED, + }} + > + <Typography sx={{ fontSize: 10 }}>TJWater 管网分析平台</Typography> + <Typography sx={{ fontSize: 10, textAlign: "right" }}> + 风险阈值:生存概率 ≤ 0.3 + </Typography> + </Box> + </Box> + </Box> + </Dialog> + </> + ); +}; + +export default HealthRiskReport; diff --git a/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx b/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx index 2def28a..07da4f0 100644 --- a/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx +++ b/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx @@ -11,10 +11,18 @@ import { Stack, Slide, Fade, + Button, } from "@mui/material"; -import { ChevronLeft, ChevronRight, BarChart } from "@mui/icons-material"; +import { + ChevronLeft, + ChevronRight, + BarChart, + DescriptionOutlined, +} from "@mui/icons-material"; import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types"; import { useHealthRisk } from "./HealthRiskContext"; +import { useProject } from "@/contexts/ProjectContext"; +import HealthRiskReport from "./HealthRiskReport"; const SIMPLE_LABELS = [ "0.0 - 0.1", @@ -30,8 +38,13 @@ const SIMPLE_LABELS = [ ]; const HealthRiskStatistics: React.FC = () => { - const { predictionResults, currentYear } = useHealthRisk(); + const { predictionResults, currentYear, analysisQueryTime } = useHealthRisk(); + const project = useProject(); const [isExpanded, setIsExpanded] = React.useState<boolean>(true); + const [isReportOpen, setIsReportOpen] = React.useState<boolean>(false); + const [reportGeneratedAt, setReportGeneratedAt] = React.useState( + () => new Date(), + ); const [hoveredYearIndex, setHoveredYearIndex] = React.useState<number | null>( null ); @@ -278,6 +291,41 @@ const HealthRiskStatistics: React.FC = () => { /> </div> <Stack direction="row" spacing={1}> + <Tooltip + title={ + predictionResults.length > 0 + ? "生成健康风险体检报告" + : "请先完成健康风险分析" + } + > + <span> + <Button + size="small" + variant="outlined" + startIcon={<DescriptionOutlined fontSize="small" />} + disabled={predictionResults.length === 0} + onClick={() => { + setReportGeneratedAt(new Date()); + setIsReportOpen(true); + }} + sx={{ + minHeight: 32, + color: "white", + borderColor: "rgba(255,255,255,0.62)", + "&:hover": { + borderColor: "white", + backgroundColor: "rgba(255,255,255,0.1)", + }, + "&.Mui-disabled": { + color: "rgba(255,255,255,0.48)", + borderColor: "rgba(255,255,255,0.24)", + }, + }} + > + 生成报告 + </Button> + </span> + </Tooltip> <Tooltip title="收起"> <IconButton size="small" @@ -308,6 +356,15 @@ const HealthRiskStatistics: React.FC = () => { </div> </div> </Slide> + <HealthRiskReport + open={isReportOpen} + onClose={() => setIsReportOpen(false)} + predictionResults={predictionResults} + currentYear={currentYear} + analysisQueryTime={analysisQueryTime} + networkName={project?.networkName || "未命名管网"} + generatedAt={reportGeneratedAt} + /> </> ); }; diff --git a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx index b0bb85a..daee04f 100644 --- a/src/components/olmap/HealthRiskAnalysis/Timeline.tsx +++ b/src/components/olmap/HealthRiskAnalysis/Timeline.tsx @@ -137,6 +137,7 @@ const Timeline: React.FC<TimelineProps> = ({ setPredictionResults, currentYear, setCurrentYear, + setAnalysisQueryTime, } = useHealthRisk(); const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date()); @@ -397,6 +398,7 @@ const Timeline: React.FC<TimelineProps> = ({ if (response.ok) { const results: PredictionResult[] = await response.json(); setPredictionResults(results); + setAnalysisQueryTime(calculationDateTime.toISOString()); open?.({ type: "success", message: `模拟预测完成,获取到 ${results.length} 条管道数据`, @@ -404,7 +406,11 @@ const Timeline: React.FC<TimelineProps> = ({ } else { // 读取后端 HTTPException 返回的 detail 信息 const errorData = await response.json().catch(() => ({})); - const errorMessage = errorData.detail || "模拟预测失败"; + const errorMessage = + typeof errorData.detail === "string" && + errorData.detail.includes("未找到流速数据") + ? "所选时间暂无可用的管网模拟数据,请确认该时刻已完成模拟计算并生成流速结果后重试。" + : errorData.detail || "模拟预测失败"; open?.({ type: "error", message: errorMessage, diff --git a/src/components/olmap/HealthRiskAnalysis/healthRiskReport.test.ts b/src/components/olmap/HealthRiskAnalysis/healthRiskReport.test.ts new file mode 100644 index 0000000..ace70fb --- /dev/null +++ b/src/components/olmap/HealthRiskAnalysis/healthRiskReport.test.ts @@ -0,0 +1,84 @@ +import { + buildHealthRiskReportData, + getProbabilityAtYear, + getRiskBandIndex, + PRIORITY_PIPE_LIMIT, +} from "./healthRiskReport"; +import { PredictionResult } from "./types"; + +const createResult = ( + linkId: string, + years: number[], + probabilities: number[], +): PredictionResult => ({ + link_id: linkId, + survival_function: { + x: years, + y: probabilities, + a: 0, + b: 0, + }, +}); + +describe("healthRiskReport", () => { + it("matches probability by the exact forecast year", () => { + const result = createResult("P-1", [4, 6], [0.8, 0.2]); + + expect(getProbabilityAtYear(result, 6)).toBe(0.2); + expect(getProbabilityAtYear(result, 5)).toBeNull(); + }); + + it("rejects missing and out-of-range probabilities", () => { + expect( + getProbabilityAtYear(createResult("P-1", [4], [Number.NaN]), 4), + ).toBeNull(); + expect(getProbabilityAtYear(createResult("P-2", [4], [1.1]), 4)).toBeNull(); + }); + + it("keeps risk break boundaries consistent with the existing legend", () => { + expect(getRiskBandIndex(0)).toBe(0); + expect(getRiskBandIndex(0.1)).toBe(0); + expect(getRiskBandIndex(0.10001)).toBe(1); + expect(getRiskBandIndex(1)).toBe(9); + }); + + it("builds the current snapshot and high-risk trend", () => { + const report = buildHealthRiskReportData( + [ + createResult("P-1", [4, 5], [0.2, 0.1]), + createResult("P-2", [4, 5], [0.5, 0.25]), + createResult("P-3", [4, 5], [0.9, 0.8]), + createResult("P-4", [5], [0.4]), + ], + 4, + ); + + expect(report.totalCount).toBe(4); + expect(report.validCount).toBe(3); + expect(report.invalidCount).toBe(1); + expect(report.highRiskCount).toBe(1); + expect(report.highRiskRatio).toBeCloseTo(1 / 3); + expect(report.averageProbability).toBeCloseTo(0.533333); + expect(report.distribution.reduce((sum, item) => sum + item.count, 0)).toBe( + 3, + ); + expect(report.trend).toEqual([ + { year: 4, count: 1, validCount: 3, ratio: 1 / 3 }, + { year: 5, count: 2, validCount: 4, ratio: 0.5 }, + ]); + }); + + it("sorts priority pipes and limits the printed detail", () => { + const results = Array.from({ length: PRIORITY_PIPE_LIMIT + 5 }, (_, index) => + createResult(`P-${PRIORITY_PIPE_LIMIT + 5 - index}`, [4], [0.2]), + ); + + const report = buildHealthRiskReportData(results, 4); + + expect(report.priorityPipes).toHaveLength(PRIORITY_PIPE_LIMIT); + expect(report.priorityPipes[0].linkId).toBe("P-1"); + expect(report.priorityPipes.at(-1)?.linkId).toBe( + `P-${PRIORITY_PIPE_LIMIT}`, + ); + }); +}); diff --git a/src/components/olmap/HealthRiskAnalysis/healthRiskReport.ts b/src/components/olmap/HealthRiskAnalysis/healthRiskReport.ts new file mode 100644 index 0000000..b00125f --- /dev/null +++ b/src/components/olmap/HealthRiskAnalysis/healthRiskReport.ts @@ -0,0 +1,201 @@ +import { + PredictionResult, + RAINBOW_COLORS, + RISK_BREAKS, + RISK_LABELS, +} from "./types"; + +export const HIGH_RISK_MAX_PROBABILITY = 0.3; +export const PRIORITY_PIPE_LIMIT = 20; + +export interface RiskDistributionItem { + label: string; + color: string; + count: number; + ratio: number; +} + +export interface HighRiskTrendPoint { + year: number; + count: number; + validCount: number; + ratio: number; +} + +export interface PriorityPipeItem { + linkId: string; + probability: number; + riskLabel: string; + color: string; +} + +export interface HealthRiskReportData { + totalCount: number; + validCount: number; + invalidCount: number; + averageProbability: number | null; + highRiskCount: number; + highRiskRatio: number; + distribution: RiskDistributionItem[]; + trend: HighRiskTrendPoint[]; + priorityPipes: PriorityPipeItem[]; + conclusion: string; + recommendations: string[]; +} + +const isValidProbability = (value: unknown): value is number => + typeof value === "number" && + Number.isFinite(value) && + value >= 0 && + value <= 1; + +export const getProbabilityAtYear = ( + result: PredictionResult, + year: number, +): number | null => { + const yearIndex = result.survival_function.x.findIndex( + (value) => value === year, + ); + if (yearIndex < 0) return null; + + const probability = result.survival_function.y[yearIndex]; + return isValidProbability(probability) ? probability : null; +}; + +export const getRiskBandIndex = (probability: number): number => { + const index = RISK_BREAKS.findIndex((upperBound) => probability <= upperBound); + return index >= 0 ? index : RISK_BREAKS.length - 1; +}; + +export const buildHealthRiskReportData = ( + results: PredictionResult[], + currentYear: number, +): HealthRiskReportData => { + const currentValues = results.flatMap((result) => { + const probability = getProbabilityAtYear(result, currentYear); + return probability === null ? [] : [{ result, probability }]; + }); + const validCount = currentValues.length; + const highRiskCount = currentValues.filter( + ({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY, + ).length; + + const distribution = RISK_LABELS.map((label, index) => { + const count = currentValues.filter( + ({ probability }) => getRiskBandIndex(probability) === index, + ).length; + return { + label, + color: RAINBOW_COLORS[index], + count, + ratio: validCount > 0 ? count / validCount : 0, + }; + }); + + const priorityPipes = currentValues + .filter( + ({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY, + ) + .sort( + (left, right) => + left.probability - right.probability || + left.result.link_id.localeCompare(right.result.link_id, "zh-CN", { + numeric: true, + }), + ) + .slice(0, PRIORITY_PIPE_LIMIT) + .map(({ result, probability }) => { + const bandIndex = getRiskBandIndex(probability); + return { + linkId: result.link_id, + probability, + riskLabel: RISK_LABELS[bandIndex], + color: RAINBOW_COLORS[bandIndex], + }; + }); + + const trendByYear = new Map< + number, + { count: number; validCount: number } + >(); + results.forEach((result) => { + const seenYears = new Set<number>(); + result.survival_function.x.forEach((year, index) => { + if ( + typeof year !== "number" || + !Number.isFinite(year) || + seenYears.has(year) + ) { + return; + } + seenYears.add(year); + const probability = result.survival_function.y[index]; + if (!isValidProbability(probability)) return; + + const point = trendByYear.get(year) ?? { count: 0, validCount: 0 }; + point.validCount += 1; + if (probability <= HIGH_RISK_MAX_PROBABILITY) point.count += 1; + trendByYear.set(year, point); + }); + }); + const trend = Array.from(trendByYear, ([year, point]) => ({ + year, + count: point.count, + validCount: point.validCount, + ratio: point.validCount > 0 ? point.count / point.validCount : 0, + })).sort((left, right) => left.year - right.year); + + const averageProbability = + validCount > 0 + ? currentValues.reduce( + (total, { probability }) => total + probability, + 0, + ) / validCount + : null; + const highRiskRatio = validCount > 0 ? highRiskCount / validCount : 0; + const mediumRiskCount = currentValues.filter( + ({ probability }) => + probability > HIGH_RISK_MAX_PROBABILITY && probability <= 0.6, + ).length; + + const conclusion = + validCount === 0 + ? `预测第 ${currentYear} 年没有可用于报告的有效生存概率数据。` + : highRiskCount > 0 + ? `预测第 ${currentYear} 年发现 ${highRiskCount} 条较高及以上风险管段,占有效结果的 ${(highRiskRatio * 100).toFixed(1)}%。建议优先核查重点管段,并结合现场检测确认处置顺序。` + : `预测第 ${currentYear} 年未发现较高及以上风险管段。当前结果仍需结合巡检记录持续跟踪。`; + + const recommendations: string[] = []; + if (highRiskCount > 0) { + recommendations.push( + "优先核查重点风险管段的运行状态、历史故障和周边施工情况,必要时安排现场检测。", + ); + } + if (mediumRiskCount > 0) { + recommendations.push( + `对其余 ${mediumRiskCount} 条中等风险管段提高监测频率,关注压力波动和风险趋势变化。`, + ); + } + if (highRiskCount === 0 && mediumRiskCount === 0 && validCount > 0) { + recommendations.push( + "保持现有巡检和监测频率,在新的在线模拟数据产生后重新评估。", + ); + } + recommendations.push( + "本报告为模型预测结果,仅用于辅助决策,不能替代现场检测、专业鉴定和安全处置流程。", + ); + + return { + totalCount: results.length, + validCount, + invalidCount: results.length - validCount, + averageProbability, + highRiskCount, + highRiskRatio, + distribution, + trend, + priorityPipes, + conclusion, + recommendations, + }; +}; diff --git a/src/components/olmap/HealthRiskAnalysis/types.ts b/src/components/olmap/HealthRiskAnalysis/types.ts index b44b481..25db29b 100644 --- a/src/components/olmap/HealthRiskAnalysis/types.ts +++ b/src/components/olmap/HealthRiskAnalysis/types.ts @@ -7,9 +7,9 @@ export interface SurvivalFunction { export interface PredictionResult { link_id: string; - diameter: number; - velocity: number; - pressure: number; + diameter?: number; + velocity?: number; + pressure?: number; survival_function: SurvivalFunction; } -- 2.54.0 From 1b2a7f4fb8f27aaeaca1a0ed2e0460c761b2362a Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 12:50:33 +0800 Subject: [PATCH 246/281] perf(map): reduce tile snapshot work during zoom --- src/components/olmap/core/MapComponent.tsx | 326 ++++++++++-------- .../olmap/core/tileFeatureIndex.test.ts | 70 ++++ src/components/olmap/core/tileFeatureIndex.ts | 255 +++++++++++++- .../olmap/core/tileSnapshotScheduler.test.ts | 46 +++ .../olmap/core/tileSnapshotScheduler.ts | 56 +++ 5 files changed, 592 insertions(+), 161 deletions(-) create mode 100644 src/components/olmap/core/tileSnapshotScheduler.test.ts create mode 100644 src/components/olmap/core/tileSnapshotScheduler.ts diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 9be03b9..3ec976b 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -18,7 +18,6 @@ import MapTools from "./MapTools"; // 导入 DeckLayer import { DeckLayer } from "@utils/layers"; import { toLonLat } from "ol/proj"; -import { along, bearing, lineString, length } from "@turf/turf"; import { Deck } from "@deck.gl/core"; import { TextLayer } from "@deck.gl/layers"; import { TripsLayer } from "@deck.gl/geo-layers"; @@ -39,11 +38,15 @@ import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime"; import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig"; import { TileFeatureIndex, - clipLineStringPartsToExtent, - coordinatesToLonLat, + buildPipeFeatureFragments, lineStringFromFlatCoordinates, + type PipeFeatureFragment, type TileFeatureInstance, } from "./tileFeatureIndex"; +import { + createTileSnapshotScheduler, + type TileSnapshotScheduler, +} from "./tileSnapshotScheduler"; interface MapComponentProps { children?: React.ReactNode; @@ -126,36 +129,6 @@ interface DataContextType { const MapContext = createContext<OlMap | undefined>(undefined); const DataContext = createContext<DataContextType | undefined>(undefined); -// 添加防抖函数 -type DebouncedFunction<F extends (...args: any[]) => any> = (( - ...args: Parameters<F> -) => void) & { - cancel: () => void; -}; - -function debounce<F extends (...args: any[]) => any>( - func: F, - waitFor: number -): DebouncedFunction<F> { - let timeout: ReturnType<typeof setTimeout> | null = null; - - const debounced = (...args: Parameters<F>): void => { - if (timeout !== null) { - clearTimeout(timeout); - } - timeout = setTimeout(() => func(...args), waitFor); - }; - - debounced.cancel = () => { - if (timeout !== null) { - clearTimeout(timeout); - timeout = null; - } - }; - - return debounced; -} - const indexCalculationRecords = (records: any[]) => new Map(records.map((record) => [String(record.ID), record])); @@ -234,6 +207,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { const compareCanvasRef = useRef<HTMLCanvasElement | null>(null); const deckLayerRef = useRef<DeckLayer | null>(null); const compareDeckLayerRef = useRef<DeckLayer | null>(null); + const tileSnapshotSchedulerRef = useRef<TileSnapshotScheduler | null>(null); + const publishedSnapshotKeyRef = useRef(""); + const pipeFragmentCacheRef = useRef( + new WeakMap<TileFeatureInstance, PipeFeatureFragment[]>(), + ); const isDisposingRef = useRef(false); const isCompareDisposingRef = useRef(false); @@ -279,6 +257,30 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { useState(false); // 控制等高线图层显示 const [showWaterflowLayer, setShowWaterflowLayer] = useState(false); // 控制等高线图层显示 const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别 + const overlayRequirementsRef = useRef({ + junctionData: false, + pipeLabels: false, + pipeFragments: false, + }); + overlayRequirementsRef.current = { + junctionData: + currentZoom >= 11 && + currentZoom <= 24 && + (showContourLayer || + (currentZoom >= 15 && + (showJunctionTextLayer || showJunctionId))), + pipeLabels: + currentZoom >= 15 && + currentZoom <= 24 && + (showPipeTextLayer || showPipeId), + pipeFragments: + currentZoom >= 12 && + currentZoom <= 24 && + isWaterflowLayerAvailable && + showWaterflowLayer && + pipeText === "flow" && + currentPipeCalData.length > 0, + }; // 实时合并计算结果到基础地理数据中 const mergedJunctionData = useMemo( @@ -346,54 +348,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { [compareDeckLayer, deckLayer, isCompareMode], ); - const buildPipeFragments = useCallback((instance: TileFeatureInstance) => { - const tileCoordinates = lineStringFromFlatCoordinates( - instance.flatCoordinates, - instance.stride, - ); - const clippedParts = clipLineStringPartsToExtent( - tileCoordinates, - instance.tileExtent, - ); - return clippedParts.flatMap((clippedCoordinates, partIndex) => { - const path = coordinatesToLonLat(clippedCoordinates); - const lineStringFeature = lineString(path); - const fragmentLength = length(lineStringFeature); - if (fragmentLength <= 0) return []; - - const timestamps = [0]; - let cumulativeLength = 0; - for (let i = 1; i < path.length; i += 1) { - cumulativeLength += length(lineString([path[i - 1], path[i]])); - timestamps.push((cumulativeLength / fragmentLength) * 10); - } - - const midPoint = along(lineStringFeature, fragmentLength / 2).geometry - .coordinates; - const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry - .coordinates; - const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry - .coordinates; - let lineAngle = bearing(prevPoint, nextPoint); - lineAngle = -lineAngle + 90; - if (lineAngle < -90 || lineAngle > 90) { - lineAngle += 180; - } - - return [ - { - instanceKey: `${instance.instanceKey}/${partIndex}`, - id: instance.featureId, - diameter: instance.properties.diameter || 0, - length: instance.properties.length || fragmentLength * 1000, - path, - position: midPoint, - angle: lineAngle, - timestamps, - fragmentLength, - }, - ]; - }); + const getPipeFragments = useCallback((instance: TileFeatureInstance) => { + const cached = pipeFragmentCacheRef.current.get(instance); + if (cached) return cached; + const fragments = buildPipeFeatureFragments(instance); + pipeFragmentCacheRef.current.set(instance, fragments); + return fragments; }, []); const publishActiveTileSnapshot = useCallback( @@ -404,60 +364,113 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { zoom, ); const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom); + if (!junctionSnapshot || !pipeSnapshot) return; - const nextJunctionData = Array.from( - junctionSnapshot?.instancesById.values() ?? [], + const requirements = overlayRequirementsRef.current; + const requirementSignature = [ + requirements.junctionData ? 1 : 0, + requirements.pipeLabels ? 1 : 0, + requirements.pipeFragments ? 1 : 0, + ].join(""); + const snapshotKey = `${junctionSnapshot.signature}::${pipeSnapshot.signature}::${requirementSignature}`; + if (publishedSnapshotKeyRef.current === snapshotKey) return; + + const junctionRepresentatives = Array.from( + junctionSnapshot.instancesById.values(), ) .map((instances) => instances[0]) - .filter(Boolean) - .map((instance) => { - const [x, y] = lineStringFromFlatCoordinates( - instance.flatCoordinates, - instance.stride, - )[0]; - return { - id: instance.featureId, - instanceKey: instance.instanceKey, - position: toLonLat([x, y]), - elevation: instance.properties.elevation || 0, - demand: instance.properties.demand || 0, - }; - }) - .sort((a, b) => String(a.id).localeCompare(String(b.id))); + .filter(Boolean); + const pipeRepresentatives = Array.from( + pipeSnapshot.instancesById.values(), + ) + .map((instances) => instances[0]) + .filter(Boolean); - const nextPipeFragments = (pipeSnapshot?.instances ?? []) - .filter((instance) => instance.geometryType.includes("Line")) - .flatMap(buildPipeFragments) - .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)); + const nextJunctionData = requirements.junctionData + ? junctionRepresentatives + .map((instance) => { + const [x, y] = lineStringFromFlatCoordinates( + instance.flatCoordinates, + instance.stride, + )[0]; + return { + id: instance.featureId, + instanceKey: instance.instanceKey, + position: toLonLat([x, y]), + elevation: instance.properties.elevation || 0, + demand: instance.properties.demand || 0, + }; + }) + .sort((a, b) => String(a.id).localeCompare(String(b.id))) + : []; + + const preparedPipeFragments = + requirements.pipeLabels || requirements.pipeFragments + ? pipeSnapshot.instances + .filter((instance) => instance.geometryType.includes("Line")) + .flatMap(getPipeFragments) + .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)) + : []; const labelById = new Map<string, any>(); - nextPipeFragments.forEach((fragment) => { - const previous = labelById.get(fragment.id); - if ( - !previous || - fragment.fragmentLength > previous.fragmentLength || - (fragment.fragmentLength === previous.fragmentLength && - fragment.instanceKey.localeCompare(previous.instanceKey) < 0) - ) { - labelById.set(fragment.id, fragment); - } - }); - const nextPipeLabels = Array.from(labelById.values()).sort((a, b) => - String(a.id).localeCompare(String(b.id)), - ); + if (requirements.pipeLabels) { + preparedPipeFragments.forEach((fragment) => { + const previous = labelById.get(fragment.id); + if ( + !previous || + fragment.fragmentLength > previous.fragmentLength || + (fragment.fragmentLength === previous.fragmentLength && + fragment.instanceKey.localeCompare(previous.instanceKey) < 0) + ) { + labelById.set(fragment.id, fragment); + } + }); + } + const nextPipeLabels = requirements.pipeLabels + ? Array.from(labelById.values()).sort((a, b) => + String(a.id).localeCompare(String(b.id)), + ) + : []; + const nextPipeFragments = requirements.pipeFragments + ? preparedPipeFragments + : []; + publishedSnapshotKeyRef.current = snapshotKey; setJunctionDataState(nextJunctionData); setPipeFragments(nextPipeFragments); setPipeDataState(nextPipeLabels); setElevationRange( - getNumericRange(nextJunctionData.map((item) => item.elevation)), + getNumericRange( + junctionRepresentatives.map( + (instance) => instance.properties.elevation || 0, + ), + ), ); setDiameterRange( - getNumericRange(nextPipeLabels.map((item) => item.diameter)), + getNumericRange( + pipeRepresentatives.map( + (instance) => instance.properties.diameter || 0, + ), + ), ); }, - [buildPipeFragments], + [getPipeFragments], ); + + useEffect(() => { + tileSnapshotSchedulerRef.current?.markDirty(); + }, [ + currentZoom, + currentPipeCalData.length, + isWaterflowLayerAvailable, + pipeText, + showContourLayer, + showJunctionId, + showJunctionTextLayer, + showPipeId, + showPipeTextLayer, + showWaterflowLayer, + ]); const operationalSources = useMemo( () => createOperationalMapSources({ @@ -497,7 +510,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { if (isDisposingRef.current) return; try { junctionIndexRef.current?.registerTile(event.tile); - scheduleActiveTileSnapshot(); + tileSnapshotScheduler.markDirty(); } catch (error) { console.error("Junction tile load error:", error); } @@ -506,7 +519,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { if (isDisposingRef.current) return; try { pipeIndexRef.current?.registerTile(event.tile); - scheduleActiveTileSnapshot(); + tileSnapshotScheduler.markDirty(); } catch (error) { console.error("Pipe tile load error:", error); } @@ -534,13 +547,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { layers: operationalResources.orderedLayers.slice(), controls: [], }); - const scheduleActiveTileSnapshot = debounce( - () => publishActiveTileSnapshot(map), - 50, - ); + const tileSnapshotScheduler = createTileSnapshotScheduler({ + publish: () => publishActiveTileSnapshot(map), + wait: 100, + }); + tileSnapshotSchedulerRef.current = tileSnapshotScheduler; junctionSource.on("tileloadend", handleJunctionTileLoadEnd); pipeSource.on("tileloadend", handlePipeTileLoadEnd); map.getInteractions().forEach(markMapResourcePersistent); + // 缩放或平移期间只登记瓦片,视图稳定后统一生成 Deck 数据。 + const handleMoveStart = () => { + tileSnapshotScheduler.beginMove(); + }; + const handleMoveEnd = () => { + if (isDisposingRef.current) return; + const view = map.getView(); + const zoom = view.getZoom() || 0; + setCurrentZoom(zoom); + junctionIndexRef.current?.scanLoadedTiles(); + pipeIndexRef.current?.scanLoadedTiles(); + tileSnapshotScheduler.endMove(); + try { + const center = view.getCenter(); + if (center) { + localStorage.setItem( + MAP_VIEW_STORAGE_KEY, + JSON.stringify({ center, zoom }), + ); + } + } catch (err) { + console.warn("Save map view failed", err); + } + }; + map.on("movestart", handleMoveStart); + map.on("moveend", handleMoveEnd); setMap(map); // 恢复上次视图;如果没有则适配 MAP_EXTENT @@ -577,29 +617,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { duration: 1000, }); } - // 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。 - const handleViewChange = debounce(() => { - if (isDisposingRef.current) return; - const view = map.getView(); - const zoom = view.getZoom() || 0; - setCurrentZoom(zoom); - junctionIndexRef.current?.scanLoadedTiles(); - pipeIndexRef.current?.scanLoadedTiles(); - scheduleActiveTileSnapshot(); - try { - const center = view.getCenter(); - if (center) { - localStorage.setItem( - MAP_VIEW_STORAGE_KEY, - JSON.stringify({ center, zoom }), - ); - } - } catch (err) { - console.warn("Save map view failed", err); - } - }, 250); - map.getView().on("change", handleViewChange); - // 初始化当前缩放级别并强制触发瓦片加载 const initializeTimer = window.setTimeout(() => { if (isDisposingRef.current) return; @@ -607,7 +624,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { setCurrentZoom(initialZoom); junctionIndexRef.current?.scanLoadedTiles(); pipeIndexRef.current?.scanLoadedTiles(); - scheduleActiveTileSnapshot(); + tileSnapshotScheduler.markDirty(); // 强制触发地图渲染,让瓦片加载事件触发 map.render(); }, 100); @@ -637,11 +654,14 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { return () => { isDisposingRef.current = true; window.clearTimeout(initializeTimer); - scheduleActiveTileSnapshot.cancel(); - handleViewChange.cancel(); + tileSnapshotScheduler.cancel(); + if (tileSnapshotSchedulerRef.current === tileSnapshotScheduler) { + tileSnapshotSchedulerRef.current = null; + } junctionSource.un("tileloadend", handleJunctionTileLoadEnd); pipeSource.un("tileloadend", handlePipeTileLoadEnd); - map.getView().un("change", handleViewChange); + map.un("movestart", handleMoveStart); + map.un("moveend", handleMoveEnd); junctionsLayer.un("change:visible", handleJunctionVisibilityChange); pipesLayer.un("change:visible", handlePipeVisibilityChange); if (deckLayerRef.current && !deckLayerRef.current.isDisposedLayer()) { @@ -659,6 +679,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => { disposeMapResources(map, { disposeLayers: false }); junctionIndexRef.current = null; pipeIndexRef.current = null; + publishedSnapshotKeyRef.current = ""; + pipeFragmentCacheRef.current = new WeakMap(); setJunctionDataState([]); setPipeDataState([]); setPipeFragments([]); diff --git a/src/components/olmap/core/tileFeatureIndex.test.ts b/src/components/olmap/core/tileFeatureIndex.test.ts index 617533d..1c5e7d4 100644 --- a/src/components/olmap/core/tileFeatureIndex.test.ts +++ b/src/components/olmap/core/tileFeatureIndex.test.ts @@ -9,9 +9,11 @@ jest.mock("ol/proj", () => ({ import { TileFeatureIndex, + buildPipeFeatureFragments, clipLineStringPartsToExtent, lineStringFromFlatCoordinates, } from "./tileFeatureIndex"; +import { along, bearing, lineString, length } from "@turf/turf"; describe("tileFeatureIndex geometry helpers", () => { it("clips a line to the tile core extent without dropping both sides", () => { @@ -98,4 +100,72 @@ describe("tileFeatureIndex geometry helpers", () => { expect(snapshot.instances).toHaveLength(2); expect(snapshot.instancesById.get("P-1")).toHaveLength(2); }); + + it("does not reparse an unchanged loaded tile", () => { + const getFeatures = jest.fn(() => [ + { + getProperties: () => ({ id: "P-1" }), + getGeometry: () => ({ + getType: () => "LineString", + getFlatCoordinates: () => [0, 0, 10, 0], + getStride: () => 2, + }), + }, + ]); + const tile = { + getTileCoord: () => [14, 1, 5], + getRevision: () => 1, + getFeatures, + }; + const source = { + sourceTiles_: { tile }, + getTileGrid: () => ({ + getTileCoordExtent: () => [0, -10, 10, 10], + }), + } as any; + + const index = new TileFeatureIndex("pipes", source); + index.scanLoadedTiles(); + index.scanLoadedTiles(); + + expect(getFeatures).toHaveBeenCalledTimes(1); + expect(index.getSnapshot(undefined, 14).signature).toContain( + "pipes/14/1/5@1", + ); + }); + + it("builds pipe geometry with the same spherical measurements in one pass", () => { + const instance = { + instanceKey: "pipes/14/1/5/0", + z: 14, + featureId: "P-1", + properties: { diameter: 100 }, + geometryType: "LineString", + tileExtent: [-1, -1, 1, 1] as [number, number, number, number], + flatCoordinates: [0, 0, 0.01, 0.005, 0.02, 0], + stride: 2, + }; + const path = lineString([ + [0, 0], + [0.01, 0.005], + [0.02, 0], + ]); + const expectedLength = length(path); + const expectedMidpoint = along(path, expectedLength / 2).geometry + .coordinates; + const expectedPrevious = along(path, expectedLength * 0.49).geometry + .coordinates; + const expectedNext = along(path, expectedLength * 0.51).geometry + .coordinates; + let expectedAngle = -bearing(expectedPrevious, expectedNext) + 90; + if (expectedAngle < -90 || expectedAngle > 90) expectedAngle += 180; + + const [fragment] = buildPipeFeatureFragments(instance); + + expect(fragment.fragmentLength).toBeCloseTo(expectedLength, 10); + expect(fragment.position[0]).toBeCloseTo(expectedMidpoint[0], 10); + expect(fragment.position[1]).toBeCloseTo(expectedMidpoint[1], 10); + expect(fragment.angle).toBeCloseTo(expectedAngle, 6); + expect(fragment.timestamps.at(-1)).toBeCloseTo(10, 10); + }); }); diff --git a/src/components/olmap/core/tileFeatureIndex.ts b/src/components/olmap/core/tileFeatureIndex.ts index c941076..f333e31 100644 --- a/src/components/olmap/core/tileFeatureIndex.ts +++ b/src/components/olmap/core/tileFeatureIndex.ts @@ -24,10 +24,34 @@ export type TileFeatureInstance = { }; type TileFeatureSnapshot = { + signature: string; instancesById: Map<string, TileFeatureInstance[]>; instances: TileFeatureInstance[]; }; +export type PipeFeatureFragment = { + instanceKey: string; + id: string; + diameter: number; + length: number; + path: [number, number][]; + position: [number, number]; + angle: number; + timestamps: number[]; + fragmentLength: number; +}; + +type RegisteredTile = { + tile: any; + sourceRevision: string | number; + signatureRevision: string | number; + z: number; +}; + +const EARTH_RADIUS_KM = 6371.0088; +const DEGREES_TO_RADIANS = Math.PI / 180; +const RADIANS_TO_DEGREES = 180 / Math.PI; + const getTileCoord = (tile: any): [number, number, number] | null => { const coord = typeof tile.getTileCoord === "function" @@ -215,11 +239,174 @@ export const coordinatesToLonLat = ( return [lon, lat]; }); +const sphericalDistance = ( + start: [number, number], + end: [number, number], +) => { + const startLatitude = start[1] * DEGREES_TO_RADIANS; + const endLatitude = end[1] * DEGREES_TO_RADIANS; + const latitudeDelta = (end[1] - start[1]) * DEGREES_TO_RADIANS; + const longitudeDelta = (end[0] - start[0]) * DEGREES_TO_RADIANS; + const haversine = + Math.sin(latitudeDelta / 2) ** 2 + + Math.sin(longitudeDelta / 2) ** 2 * + Math.cos(startLatitude) * + Math.cos(endLatitude); + const normalizedHaversine = Math.min(1, Math.max(0, haversine)); + return ( + 2 * + EARTH_RADIUS_KM * + Math.atan2( + Math.sqrt(normalizedHaversine), + Math.sqrt(1 - normalizedHaversine), + ) + ); +}; + +const sphericalBearing = ( + start: [number, number], + end: [number, number], +) => { + const startLatitude = start[1] * DEGREES_TO_RADIANS; + const endLatitude = end[1] * DEGREES_TO_RADIANS; + const longitudeDelta = (end[0] - start[0]) * DEGREES_TO_RADIANS; + return ( + Math.atan2( + Math.sin(longitudeDelta) * Math.cos(endLatitude), + Math.cos(startLatitude) * Math.sin(endLatitude) - + Math.sin(startLatitude) * + Math.cos(endLatitude) * + Math.cos(longitudeDelta), + ) * RADIANS_TO_DEGREES + ); +}; + +const sphericalDestination = ( + origin: [number, number], + distance: number, + bearing: number, +): [number, number] => { + const longitude = origin[0] * DEGREES_TO_RADIANS; + const latitude = origin[1] * DEGREES_TO_RADIANS; + const bearingRadians = bearing * DEGREES_TO_RADIANS; + const angularDistance = distance / EARTH_RADIUS_KM; + const destinationLatitude = Math.asin( + Math.sin(latitude) * Math.cos(angularDistance) + + Math.cos(latitude) * + Math.sin(angularDistance) * + Math.cos(bearingRadians), + ); + const destinationLongitude = + longitude + + Math.atan2( + Math.sin(bearingRadians) * + Math.sin(angularDistance) * + Math.cos(latitude), + Math.cos(angularDistance) - + Math.sin(latitude) * Math.sin(destinationLatitude), + ); + return [ + destinationLongitude * RADIANS_TO_DEGREES, + destinationLatitude * RADIANS_TO_DEGREES, + ]; +}; + +const coordinateAtDistance = ( + path: [number, number][], + cumulativeLengths: Float64Array, + targetDistance: number, +): [number, number] => { + let index = 1; + while ( + index < cumulativeLengths.length && + cumulativeLengths[index] < targetDistance + ) { + index += 1; + } + if (index >= path.length) return path[path.length - 1]; + if (cumulativeLengths[index] === targetDistance) return path[index]; + + const segmentStart = path[index - 1]; + const remainingDistance = + targetDistance - cumulativeLengths[index - 1]; + return sphericalDestination( + segmentStart, + remainingDistance, + sphericalBearing(segmentStart, path[index]), + ); +}; + +export const buildPipeFeatureFragments = ( + instance: TileFeatureInstance, +): PipeFeatureFragment[] => { + const tileCoordinates = lineStringFromFlatCoordinates( + instance.flatCoordinates, + instance.stride, + ); + return clipLineStringPartsToExtent( + tileCoordinates, + instance.tileExtent, + ).flatMap((clippedCoordinates, partIndex) => { + const path = coordinatesToLonLat(clippedCoordinates); + if (path.length < 2) return []; + + const cumulativeLengths = new Float64Array(path.length); + let fragmentLength = 0; + for (let index = 1; index < path.length; index += 1) { + fragmentLength += sphericalDistance(path[index - 1], path[index]); + cumulativeLengths[index] = fragmentLength; + } + if (fragmentLength <= 0) return []; + + const timestamps = new Array<number>(path.length); + timestamps[0] = 0; + for (let index = 1; index < path.length; index += 1) { + timestamps[index] = + (cumulativeLengths[index] / fragmentLength) * 10; + } + + const previousPoint = coordinateAtDistance( + path, + cumulativeLengths, + fragmentLength * 0.49, + ); + const position = coordinateAtDistance( + path, + cumulativeLengths, + fragmentLength * 0.5, + ); + const nextPoint = coordinateAtDistance( + path, + cumulativeLengths, + fragmentLength * 0.51, + ); + let angle = -sphericalBearing(previousPoint, nextPoint) + 90; + if (angle < -90 || angle > 90) angle += 180; + + return [ + { + instanceKey: `${instance.instanceKey}/${partIndex}`, + id: instance.featureId, + diameter: instance.properties.diameter || 0, + length: instance.properties.length || fragmentLength * 1000, + path, + position, + angle, + timestamps, + fragmentLength, + }, + ]; + }); +}; + export class TileFeatureIndex { private readonly instancesByTile = new Map< TileKey, TileFeatureInstance[] >(); + private readonly registeredTiles = new Map<TileKey, RegisteredTile>(); + private readonly tileKeysByZoom = new Map<number, Set<TileKey>>(); + private nextRevision = 0; constructor( private readonly sourceKey: string, @@ -246,6 +433,19 @@ export class TileFeatureIndex { if (!extent) return null; const tileKey = getTileKey(this.sourceKey, z, x, y); + const tileRevision = + typeof tile.getRevision === "function" + ? tile.getRevision() + : `${typeof tile.getState === "function" ? tile.getState() : ""}`; + const registeredTile = this.registeredTiles.get(tileKey); + if ( + registeredTile && + registeredTile.tile === tile && + registeredTile.sourceRevision === tileRevision + ) { + return tileKey; + } + const tileInstances: TileFeatureInstance[] = []; const renderFeatures = tile.getFeatures() ?? []; renderFeatures.forEach((renderFeature: any, featureOrdinal: number) => { @@ -279,16 +479,39 @@ export class TileFeatureIndex { if (tileInstances.length === 0) { this.instancesByTile.delete(tileKey); + const zoomTileKeys = this.tileKeysByZoom.get(z); + zoomTileKeys?.delete(tileKey); + if (zoomTileKeys?.size === 0) this.tileKeysByZoom.delete(z); } else { this.instancesByTile.set(tileKey, tileInstances); + const zoomTileKeys = this.tileKeysByZoom.get(z); + if (zoomTileKeys) zoomTileKeys.add(tileKey); + else this.tileKeysByZoom.set(z, new Set([tileKey])); } + this.nextRevision += 1; + this.registeredTiles.set(tileKey, { + tile, + sourceRevision: tileRevision, + signatureRevision: + tileRevision === "" ? `local-${this.nextRevision}` : tileRevision, + z, + }); return tileKey; } private pruneMissingTiles(activeTileKeys: Set<TileKey>) { - Array.from(this.instancesByTile.keys()).forEach((tileKey) => { + Array.from(this.registeredTiles.keys()).forEach((tileKey) => { if (!activeTileKeys.has(tileKey)) { + const registration = this.registeredTiles.get(tileKey); this.instancesByTile.delete(tileKey); + this.registeredTiles.delete(tileKey); + if (registration) { + const zoomTileKeys = this.tileKeysByZoom.get(registration.z); + zoomTileKeys?.delete(tileKey); + if (zoomTileKeys?.size === 0) { + this.tileKeysByZoom.delete(registration.z); + } + } } }); } @@ -301,13 +524,21 @@ export class TileFeatureIndex { ? tileGrid.getZForResolution(resolution, (this.source as any).zDirection) : Math.max(0, Math.round(zoom)); const viewExtent = map?.getView().calculateExtent(map.getSize()); - const instances = Array.from(this.instancesByTile.values()) - .flat() - .filter((instance) => instance.z === targetZ) - .filter( - (instance) => - !viewExtent || intersects(instance.tileExtent, viewExtent), - ) + const activeTileKeys = Array.from( + this.tileKeysByZoom.get(targetZ) ?? [], + ) + .filter((tileKey) => { + const tileInstances = this.instancesByTile.get(tileKey); + return ( + tileInstances && + tileInstances.length > 0 && + (!viewExtent || + intersects(tileInstances[0].tileExtent, viewExtent)) + ); + }) + .sort(); + const instances = activeTileKeys + .flatMap((tileKey) => this.instancesByTile.get(tileKey) ?? []) .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)); const instancesById = new Map<string, TileFeatureInstance[]>(); @@ -317,6 +548,12 @@ export class TileFeatureIndex { else instancesById.set(instance.featureId, [instance]); }); - return { instancesById, instances }; + const signature = `${targetZ}:${activeTileKeys + .map((tileKey) => { + const registration = this.registeredTiles.get(tileKey); + return `${tileKey}@${registration?.signatureRevision ?? "unknown"}`; + }) + .join("|")}`; + return { signature, instancesById, instances }; } } diff --git a/src/components/olmap/core/tileSnapshotScheduler.test.ts b/src/components/olmap/core/tileSnapshotScheduler.test.ts new file mode 100644 index 0000000..a33334b --- /dev/null +++ b/src/components/olmap/core/tileSnapshotScheduler.test.ts @@ -0,0 +1,46 @@ +import { createTileSnapshotScheduler } from "./tileSnapshotScheduler"; + +describe("createTileSnapshotScheduler", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("defers and coalesces tile snapshots until movement ends", () => { + const publish = jest.fn(); + const scheduler = createTileSnapshotScheduler({ publish, wait: 50 }); + + scheduler.beginMove(); + scheduler.markDirty(); + scheduler.markDirty(); + jest.advanceTimersByTime(100); + expect(publish).not.toHaveBeenCalled(); + + scheduler.endMove(); + jest.advanceTimersByTime(49); + expect(publish).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it("batches settled tile arrivals and cancels pending work", () => { + const publish = jest.fn(); + const scheduler = createTileSnapshotScheduler({ publish, wait: 50 }); + + scheduler.markDirty(); + jest.advanceTimersByTime(25); + scheduler.markDirty(); + jest.advanceTimersByTime(49); + expect(publish).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(publish).toHaveBeenCalledTimes(1); + + scheduler.markDirty(); + scheduler.cancel(); + jest.runAllTimers(); + expect(publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/olmap/core/tileSnapshotScheduler.ts b/src/components/olmap/core/tileSnapshotScheduler.ts new file mode 100644 index 0000000..032f091 --- /dev/null +++ b/src/components/olmap/core/tileSnapshotScheduler.ts @@ -0,0 +1,56 @@ +type TileSnapshotSchedulerOptions = { + publish: () => void; + wait?: number; +}; + +export type TileSnapshotScheduler = { + beginMove: () => void; + endMove: () => void; + markDirty: () => void; + cancel: () => void; +}; + +export const createTileSnapshotScheduler = ({ + publish, + wait = 100, +}: TileSnapshotSchedulerOptions): TileSnapshotScheduler => { + let moving = false; + let dirty = false; + let timer: ReturnType<typeof setTimeout> | null = null; + + const clearTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const schedule = () => { + dirty = true; + if (moving) return; + clearTimer(); + timer = setTimeout(() => { + timer = null; + if (moving || !dirty) return; + dirty = false; + publish(); + }, wait); + }; + + return { + beginMove: () => { + moving = true; + clearTimer(); + }, + endMove: () => { + moving = false; + schedule(); + }, + markDirty: schedule, + cancel: () => { + moving = false; + dirty = false; + clearTimer(); + }, + }; +}; -- 2.54.0 From c4246cf25f50057807698f1a648c86d294476f68 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 13:16:05 +0800 Subject: [PATCH 247/281] feat(burst): add analysis report and valve binding Replace the obsolete location result with a printable analysis report, bind valve analysis to the selected scheme, and cache report diameter lookups. Scheme identity is tracked with valve results so reports cannot reuse results from another scheme that shares the same pipe. --- .../BurstSimulation/AnalysisReport.test.tsx | 200 ++++++ .../olmap/BurstSimulation/AnalysisReport.tsx | 592 ++++++++++++++++++ .../BurstPipeAnalysisPanel.test.tsx | 154 +++++ .../BurstPipeAnalysisPanel.tsx | 68 +- .../olmap/BurstSimulation/LocationResults.tsx | 416 ------------ .../olmap/BurstSimulation/SchemeQuery.tsx | 11 +- .../olmap/BurstSimulation/ValveIsolation.tsx | 91 ++- src/components/olmap/BurstSimulation/types.ts | 9 - .../BurstSimulation/valveIsolationScope.ts | 4 + 9 files changed, 1082 insertions(+), 463 deletions(-) create mode 100644 src/components/olmap/BurstSimulation/AnalysisReport.test.tsx create mode 100644 src/components/olmap/BurstSimulation/AnalysisReport.tsx create mode 100644 src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx delete mode 100644 src/components/olmap/BurstSimulation/LocationResults.tsx create mode 100644 src/components/olmap/BurstSimulation/valveIsolationScope.ts diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx new file mode 100644 index 0000000..b33ef70 --- /dev/null +++ b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx @@ -0,0 +1,200 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import AnalysisReport, { + clearAnalysisReportDiameterCache, + matchesValveAnalysis, +} from "./AnalysisReport"; +import { SchemeRecord, ValveIsolationResult } from "./types"; +import { isAllowedAccidentPipe } from "./valveIsolationScope"; + +jest.mock("@/utils/mapQueryService", () => ({ + queryFeaturesByIds: jest.fn(), +})); + +const scheme: SchemeRecord = { + id: 17, + schemeName: "burst-report-demo", + type: "burst_analysis", + user: "operator", + create_time: "2026-07-30T08:00:00+08:00", + startTime: "2026-07-30T09:00:00+08:00", + schemeDetail: { + burst_ID: ["P-1", "P-2"], + burst_size: [120, 80], + modify_total_duration: 5400, + modify_fixed_pump_pattern: null, + modify_valve_opening: null, + modify_variable_pump_pattern: null, + }, +}; + +const valveResult: ValveIsolationResult = { + accident_elements: ["P-1"], + affected_nodes: ["J-1", "J-2"], + must_close_valves: ["V-1"], + optional_valves: ["V-2"], + isolatable: true, +}; + +const feature = (id: string, diameter: number) => ({ + getProperties: () => ({ id, diameter }), +}); + +describe("AnalysisReport", () => { + beforeEach(() => { + (queryFeaturesByIds as jest.Mock).mockImplementation( + async (_ids: string[], layerName: string) => + layerName === "geo_pipes_mat" + ? [feature("P-1", 315)] + : [feature("P-2", 800)], + ); + }); + + afterEach(() => { + clearAnalysisReportDiameterCache(); + jest.clearAllMocks(); + document.body.classList.remove("burst-analysis-report-printing"); + }); + + it("renders the selected scheme, pipe data, and matching valve result", async () => { + render( + <AnalysisReport + scheme={scheme} + valveResult={valveResult} + disabledValves={["V-3"]} + generatedAt={new Date("2026-07-30T10:00:00+08:00")} + />, + ); + + const preview = within( + screen.getByTestId("burst-analysis-report-preview"), + ); + expect( + preview.getByRole("heading", { name: "爆管分析报告" }), + ).toBeInTheDocument(); + expect(preview.getByText("burst-report-demo")).toBeInTheDocument(); + expect(preview.getByText("可隔离")).toBeInTheDocument(); + expect(preview.getByText("V-1")).toBeInTheDocument(); + expect(preview.getByText("V-3")).toBeInTheDocument(); + + await waitFor(() => { + expect(preview.getByText("315 mm")).toBeInTheDocument(); + expect(preview.getByText("800 mm")).toBeInTheDocument(); + }); + expect(queryFeaturesByIds).toHaveBeenCalledWith( + ["P-2"], + "geo_pipes", + ); + }); + + it("does not mix an unrelated valve analysis into the report", async () => { + render( + <AnalysisReport + scheme={scheme} + valveResult={{ ...valveResult, accident_elements: ["P-99"] }} + disabledValves={[]} + generatedAt={new Date("2026-07-30T10:00:00+08:00")} + />, + ); + + const preview = within( + screen.getByTestId("burst-analysis-report-preview"), + ); + expect( + preview.getByText(/尚未对本方案执行匹配的关阀分析/), + ).toBeInTheDocument(); + expect(preview.queryByText("V-1")).not.toBeInTheDocument(); + expect( + matchesValveAnalysis(scheme, { + ...valveResult, + accident_elements: ["P-99"], + }), + ).toBe(false); + await waitFor(() => + expect(preview.getByText("315 mm")).toBeInTheDocument(), + ); + }); + + it("limits scheme-bound valve analysis to the scheme accident pipes", () => { + expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true); + expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false); + expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true); + }); + + it("prints only after assigning the report print state", async () => { + const originalTitle = document.title; + const print = jest + .spyOn(window, "print") + .mockImplementation(() => undefined); + + render( + <AnalysisReport + scheme={scheme} + valveResult={null} + disabledValves={[]} + generatedAt={new Date("2026-07-30T10:00:00+08:00")} + />, + ); + + const preview = within( + screen.getByTestId("burst-analysis-report-preview"), + ); + await waitFor(() => + expect(preview.getByText("315 mm")).toBeInTheDocument(), + ); + expect( + document.querySelector(".burst-analysis-report-print-root"), + ).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "打印/保存 PDF" }), + ); + expect(print).toHaveBeenCalledTimes(1); + expect( + document.querySelector(".burst-analysis-report-print-root"), + ).toBeInTheDocument(); + expect(document.body).toHaveClass("burst-analysis-report-printing"); + expect(document.title).toBe("爆管分析报告-burst-report-demo"); + + fireEvent(window, new Event("afterprint")); + expect(document.body).not.toHaveClass( + "burst-analysis-report-printing", + ); + expect(document.title).toBe(originalTitle); + print.mockRestore(); + }); + + it("reuses pipe diameters after the report is remounted", async () => { + const props = { + scheme, + valveResult: null, + disabledValves: [], + generatedAt: new Date("2026-07-30T10:00:00+08:00"), + }; + const firstRender = render(<AnalysisReport {...props} />); + + await waitFor(() => + expect( + within(screen.getByTestId("burst-analysis-report-preview")).getByText( + "800 mm", + ), + ).toBeInTheDocument(), + ); + expect(queryFeaturesByIds).toHaveBeenCalledTimes(2); + + firstRender.unmount(); + render(<AnalysisReport {...props} />); + + expect( + within(screen.getByTestId("burst-analysis-report-preview")).getByText( + "800 mm", + ), + ).toBeInTheDocument(); + expect(queryFeaturesByIds).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.tsx new file mode 100644 index 0000000..519eea2 --- /dev/null +++ b/src/components/olmap/BurstSimulation/AnalysisReport.tsx @@ -0,0 +1,592 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Alert, + Box, + Button, + Chip, + GlobalStyles, + Paper, + Portal, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { + DescriptionOutlined, + PrintOutlined, +} from "@mui/icons-material"; +import { NETWORK_NAME } from "@config/config"; +import { queryFeaturesByIds } from "@/utils/mapQueryService"; +import { + getPipeDiameterDisplay, + type PipeDiameterMap, +} from "./schemePipeDiameters"; +import { SchemeRecord, ValveIsolationResult } from "./types"; + +interface AnalysisReportProps { + scheme: SchemeRecord | null; + valveResult: ValveIsolationResult | null; + disabledValves: string[]; + generatedAt: Date | null; +} + +interface ReportDocumentProps extends AnalysisReportProps { + diameters: PipeDiameterMap; + loadingDiameters: boolean; +} + +const REPORT_BLUE = "#0b4f87"; +const REPORT_INK = "#172435"; +const REPORT_MUTED = "#5f6f80"; +const REPORT_LINE = "#d8e0e8"; +const diameterCache = new Map<string, PipeDiameterMap>(); +const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>(); + +export const clearAnalysisReportDiameterCache = () => { + diameterCache.clear(); + diameterRequestCache.clear(); +}; + +const loadPipeDiameters = async ( + pipeIds: string[], + queryKey: string, +): Promise<PipeDiameterMap> => { + const cachedDiameters = diameterCache.get(queryKey); + if (cachedDiameters) { + return cachedDiameters; + } + + const cachedRequest = diameterRequestCache.get(queryKey); + if (cachedRequest) { + return cachedRequest; + } + + const request = (async () => { + let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat"); + const foundIds = new Set( + features.map((feature) => String(feature.getProperties().id)), + ); + const missingIds = pipeIds.filter((pipeId) => !foundIds.has(pipeId)); + if (missingIds.length) { + features = [ + ...features, + ...(await queryFeaturesByIds(missingIds, "geo_pipes")), + ]; + } + + const diameters: PipeDiameterMap = Object.fromEntries( + pipeIds.map((pipeId) => [pipeId, null]), + ); + features.forEach((feature) => { + const properties = feature.getProperties(); + const pipeId = String(properties.id); + const diameter = Number(properties.diameter); + if (pipeIds.includes(pipeId)) { + diameters[pipeId] = Number.isFinite(diameter) ? diameter : null; + } + }); + + diameterCache.set(queryKey, diameters); + return diameters; + })(); + + diameterRequestCache.set(queryKey, request); + try { + return await request; + } finally { + if (diameterRequestCache.get(queryKey) === request) { + diameterRequestCache.delete(queryKey); + } + } +}; + +const formatDateTime = (value: Date | string | null | undefined) => { + if (!value) return "未记录"; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return "未记录"; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(date); +}; + +const formatDuration = (seconds: number | undefined) => { + if (!Number.isFinite(seconds) || seconds === undefined || seconds < 0) { + return "未记录"; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + return [ + hours ? `${hours} 小时` : "", + minutes ? `${minutes} 分钟` : "", + remainingSeconds || (!hours && !minutes) ? `${remainingSeconds} 秒` : "", + ] + .filter(Boolean) + .join(" "); +}; + +export const matchesValveAnalysis = ( + scheme: SchemeRecord | null, + valveResult: ValveIsolationResult | null, +) => { + const schemePipeIds = new Set(scheme?.schemeDetail?.burst_ID ?? []); + const accidentElements = valveResult?.accident_elements ?? []; + return ( + valveResult !== null && + accidentElements.length > 0 && + accidentElements.every((pipeId) => schemePipeIds.has(pipeId)) + ); +}; + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( + <Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.5 }}> + <Box sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }} /> + <Typography + component="h2" + sx={{ color: REPORT_INK, fontSize: 17, fontWeight: 700 }} + > + {children} + </Typography> + </Stack> +); + +const IdList = ({ + values, + emptyText = "无", +}: { + values: string[] | undefined; + emptyText?: string; +}) => + values?.length ? ( + <Box className="flex flex-wrap gap-1.5"> + {values.map((value) => ( + <Chip + key={value} + label={value} + size="small" + variant="outlined" + sx={{ borderColor: REPORT_LINE, color: REPORT_INK }} + /> + ))} + </Box> + ) : ( + <Typography variant="body2" sx={{ color: REPORT_MUTED }}> + {emptyText} + </Typography> + ); + +const ReportDocument: React.FC<ReportDocumentProps> = ({ + scheme, + valveResult, + disabledValves, + generatedAt, + diameters, + loadingDiameters, +}) => { + if (!scheme) return null; + + const pipeIds = scheme.schemeDetail?.burst_ID ?? []; + const burstSizes = scheme.schemeDetail?.burst_size ?? []; + const matchedValveResult = matchesValveAnalysis(scheme, valveResult) + ? valveResult + : null; + const duration = scheme.schemeDetail?.modify_total_duration; + + return ( + <Box + sx={{ + width: "100%", + minHeight: "100%", + bgcolor: "#fff", + color: REPORT_INK, + p: { xs: 2, sm: 3 }, + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', + }} + > + <Box + sx={{ + borderBottom: `3px solid ${REPORT_BLUE}`, + pb: 2, + mb: 3, + }} + > + <Typography + component="h1" + sx={{ color: REPORT_BLUE, fontSize: 26, fontWeight: 800 }} + > + 爆管分析报告 + </Typography> + <Typography sx={{ mt: 0.75, color: REPORT_MUTED, fontSize: 13 }}> + 报告编号:BA-{scheme.id} 生成时间:{formatDateTime(generatedAt)} + </Typography> + </Box> + + <Box sx={{ mb: 3, breakInside: "avoid" }}> + <SectionTitle>方案概况</SectionTitle> + <Box + sx={{ + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + borderTop: `1px solid ${REPORT_LINE}`, + borderLeft: `1px solid ${REPORT_LINE}`, + }} + > + {[ + ["管网", NETWORK_NAME], + ["方案名称", scheme.schemeName], + ["方案创建人", scheme.user || "未记录"], + ["方案创建时间", formatDateTime(scheme.create_time)], + ["模拟开始时间", formatDateTime(scheme.startTime)], + ["模拟持续时间", formatDuration(duration)], + ].map(([label, value]) => ( + <Box + key={label} + sx={{ + p: 1.25, + borderRight: `1px solid ${REPORT_LINE}`, + borderBottom: `1px solid ${REPORT_LINE}`, + }} + > + <Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}> + {label} + </Typography> + <Typography sx={{ mt: 0.3, fontSize: 14, fontWeight: 600 }}> + {value} + </Typography> + </Box> + ))} + </Box> + </Box> + + <Box sx={{ mb: 3, breakInside: "avoid" }}> + <SectionTitle>爆管模拟参数</SectionTitle> + <TableContainer component={Paper} variant="outlined" elevation={0}> + <Table size="small"> + <TableHead> + <TableRow sx={{ bgcolor: "#f3f7fa" }}> + <TableCell>序号</TableCell> + <TableCell>爆管管段</TableCell> + <TableCell>管径</TableCell> + <TableCell align="right">爆管面积</TableCell> + </TableRow> + </TableHead> + <TableBody> + {pipeIds.length ? ( + pipeIds.map((pipeId, index) => ( + <TableRow key={`${pipeId}-${index}`}> + <TableCell>{index + 1}</TableCell> + <TableCell sx={{ fontWeight: 600 }}>{pipeId}</TableCell> + <TableCell> + {getPipeDiameterDisplay( + [pipeId], + diameters, + loadingDiameters, + )} + </TableCell> + <TableCell align="right"> + {Number.isFinite(burstSizes[index]) + ? `${burstSizes[index]} cm²` + : "未记录"} + </TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={4} align="center"> + 未记录爆管管段 + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </TableContainer> + </Box> + + <Box sx={{ mb: 3, breakInside: "avoid" }}> + <SectionTitle>分析结论</SectionTitle> + <Alert + severity={matchedValveResult?.isolatable === false ? "warning" : "info"} + variant="outlined" + > + 本方案记录了 {pipeIds.length} 条爆管管段,模拟持续时间为 + {formatDuration(duration)}。 + {matchedValveResult + ? ` 当前关阀分析判定事故管段${ + matchedValveResult.isolatable ? "可以" : "无法" + }有效隔离。` + : " 当前会话尚未对本方案执行匹配的关阀分析。"} + </Alert> + </Box> + + <Box sx={{ mb: 2 }}> + <SectionTitle>关阀分析</SectionTitle> + {matchedValveResult ? ( + <Stack spacing={2}> + <Box className="grid grid-cols-3 gap-2"> + {[ + ["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"], + ["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`], + ["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0} 个`], + ].map(([label, value]) => ( + <Paper + key={label} + variant="outlined" + sx={{ p: 1.5, textAlign: "center", breakInside: "avoid" }} + > + <Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}> + {label} + </Typography> + <Typography sx={{ mt: 0.5, fontWeight: 700 }}> + {value} + </Typography> + </Paper> + ))} + </Box> + {[ + ["已分析事故管段", matchedValveResult.accident_elements], + ["必关阀门", matchedValveResult.must_close_valves], + ["可选阀门", matchedValveResult.optional_valves], + ["不可用阀门", disabledValves], + ["受影响节点", matchedValveResult.affected_nodes], + ].map(([label, values]) => ( + <Box key={label as string} sx={{ breakInside: "avoid" }}> + <Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}> + {label as string} + </Typography> + <IdList values={values as string[]} /> + </Box> + ))} + </Stack> + ) : ( + <Alert severity="info" variant="outlined"> + 本方案尚未执行关阀分析。可在“关阀分析”页签完成分析后重新查看报告。 + </Alert> + )} + </Box> + </Box> + ); +}; + +const AnalysisReport: React.FC<AnalysisReportProps> = ({ + scheme, + valveResult, + disabledValves, + generatedAt, +}) => { + const pipeIds = useMemo( + () => scheme?.schemeDetail?.burst_ID ?? [], + [scheme], + ); + const diameterQueryKey = pipeIds.join("\u0000"); + const cachedDiameters = diameterCache.get(diameterQueryKey); + const [diameterState, setDiameterState] = useState<{ + queryKey: string | null; + values: PipeDiameterMap; + }>({ queryKey: null, values: {} }); + const [printReady, setPrintReady] = useState(false); + const printStateRef = useRef<{ title: string } | null>(null); + const diameters = useMemo( + () => + diameterState.queryKey === diameterQueryKey + ? diameterState.values + : cachedDiameters ?? {}, + [cachedDiameters, diameterQueryKey, diameterState], + ); + const loadingDiameters = + pipeIds.length > 0 && + diameterState.queryKey !== diameterQueryKey && + !cachedDiameters; + + useEffect(() => { + if (!pipeIds.length) { + return; + } + if (diameterCache.has(diameterQueryKey)) { + return; + } + + let cancelled = false; + + loadPipeDiameters(pipeIds, diameterQueryKey) + .then((nextDiameters) => { + if (!cancelled) { + setDiameterState({ + queryKey: diameterQueryKey, + values: nextDiameters, + }); + } + }) + .catch((error) => { + console.error("查询分析报告管径失败:", error); + if (!cancelled) { + setDiameterState({ + queryKey: diameterQueryKey, + values: Object.fromEntries( + pipeIds.map((pipeId) => [pipeId, null]), + ), + }); + } + }); + + return () => { + cancelled = true; + }; + }, [diameterQueryKey, pipeIds]); + + const restoreAfterPrint = useCallback(() => { + if (printStateRef.current) { + document.title = printStateRef.current.title; + printStateRef.current = null; + document.body.classList.remove("burst-analysis-report-printing"); + } + setPrintReady(false); + }, []); + + useEffect( + () => () => { + restoreAfterPrint(); + }, + [restoreAfterPrint], + ); + + const handlePrint = () => { + if (!scheme || printStateRef.current) return; + printStateRef.current = { title: document.title }; + document.title = `爆管分析报告-${scheme.schemeName}`; + document.body.classList.add("burst-analysis-report-printing"); + setPrintReady(true); + }; + + useEffect(() => { + if (!printReady) return; + + window.addEventListener("afterprint", restoreAfterPrint, { once: true }); + try { + window.print(); + } catch (error) { + console.error("打印爆管分析报告失败:", error); + window.setTimeout(restoreAfterPrint, 0); + } + return () => { + window.removeEventListener("afterprint", restoreAfterPrint); + }; + }, [printReady, restoreAfterPrint]); + + const reportDocument = useMemo( + () => ( + <ReportDocument + scheme={scheme} + valveResult={valveResult} + disabledValves={disabledValves} + generatedAt={generatedAt} + diameters={diameters} + loadingDiameters={loadingDiameters} + /> + ), + [ + diameters, + disabledValves, + generatedAt, + loadingDiameters, + scheme, + valveResult, + ], + ); + + if (!scheme) { + return ( + <Box className="flex h-full flex-col items-center justify-center px-6 text-center"> + <DescriptionOutlined sx={{ mb: 2, fontSize: 52, color: "#94a3b8" }} /> + <Typography variant="h6" className="font-bold text-gray-700"> + 等待选择分析方案 + </Typography> + <Typography variant="body2" className="mt-2 text-gray-500"> + 请在“方案查询”中点击“查看分析报告”。 + </Typography> + </Box> + ); + } + + return ( + <> + <GlobalStyles + styles={{ + ".burst-analysis-report-print-root": { display: "none" }, + "@page": { size: "A4 portrait", margin: 0 }, + "@media print": { + "html, body": { + width: "210mm", + minHeight: "297mm", + margin: 0, + padding: 0, + backgroundColor: "#fff", + }, + "body.burst-analysis-report-printing > *:not(.burst-analysis-report-print-root)": + { display: "none !important" }, + ".burst-analysis-report-print-root": { + display: "block !important", + width: "210mm !important", + minHeight: "297mm !important", + backgroundColor: "#fff !important", + }, + ".burst-analysis-report-print-root > div": { + padding: "14mm 16mm !important", + }, + }, + }} + /> + <Box className="space-y-3"> + <Box className="flex justify-end"> + <Button + variant="contained" + size="small" + startIcon={<PrintOutlined />} + onClick={handlePrint} + > + 打印/保存 PDF + </Button> + </Box> + <Paper + data-testid="burst-analysis-report-preview" + variant="outlined" + sx={{ overflow: "hidden" }} + > + {reportDocument} + </Paper> + </Box> + {printReady && ( + <Portal> + <Box + className="burst-analysis-report-print-root" + aria-hidden="true" + > + {reportDocument} + </Box> + </Portal> + )} + </> + ); +}; + +export default AnalysisReport; diff --git a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx new file mode 100644 index 0000000..7e6b7a6 --- /dev/null +++ b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import BurstPipeAnalysisPanel from "./BurstPipeAnalysisPanel"; + +jest.mock("./AnalysisParameters", () => ({ + __esModule: true, + default: () => <div>analysis parameters</div>, + createBurstAnalysisParametersState: () => ({}), +})); + +jest.mock("./SchemeQuery", () => ({ + __esModule: true, + default: ({ + onViewReport, + }: { + onViewReport: (scheme: Record<string, unknown>) => void; + }) => { + const createScheme = (id: number, schemeName: string) => ({ + id, + schemeName, + type: "burst_analysis", + user: "operator", + create_time: "2026-07-30T08:00:00+08:00", + startTime: "2026-07-30T09:00:00+08:00", + schemeDetail: { + burst_ID: ["P-1", "P-2"], + burst_size: [120, 80], + }, + }); + + return ( + <> + <button onClick={() => onViewReport(createScheme(1, "selected-scheme"))}> + mock report action + </button> + <button onClick={() => onViewReport(createScheme(2, "other-scheme"))}> + mock other report action + </button> + </> + ); + }, + createBurstSchemeQueryState: () => ({}), +})); + +jest.mock("./AnalysisReport", () => ({ + __esModule: true, + default: ({ + scheme, + valveResult, + }: { + scheme: { schemeName: string } | null; + valveResult: { accident_elements: string[] } | null; + }) => ( + <div> + report:{scheme?.schemeName ?? "empty"}:valve: + {valveResult?.accident_elements.join(",") ?? "none"} + </div> + ), + matchesValveAnalysis: ( + scheme: { schemeDetail: { burst_ID: string[] } }, + valveResult: { accident_elements: string[] } | null, + ) => + valveResult !== null && + valveResult.accident_elements.every((pipeId) => + scheme.schemeDetail.burst_ID.includes(pipeId), + ), +})); + +jest.mock("./ValveIsolation", () => ({ + __esModule: true, + default: ({ + allowedPipeIds, + sourceSchemeName, + onResultChange, + }: { + allowedPipeIds?: string[]; + sourceSchemeName?: string; + onResultChange: (result: Record<string, unknown>) => void; + }) => ( + <> + <div data-testid="valve-scope"> + valve scope:{sourceSchemeName ?? "independent"}: + {allowedPipeIds?.join(",") ?? "any"} + </div> + <button + onClick={() => + onResultChange({ + accident_elements: ["P-1"], + affected_nodes: ["J-1"], + must_close_valves: ["V-1"], + optional_valves: [], + isolatable: true, + }) + } + > + mock valve result + </button> + </> + ), + createValveIsolationState: () => ({ + selectedPipeId: null, + activeStep: 0, + expandedResult: true, + disabledValves: [], + }), +})); + +describe("BurstPipeAnalysisPanel", () => { + it("replaces the obsolete location tab and opens the selected report", () => { + render(<BurstPipeAnalysisPanel />); + + expect( + screen.queryByRole("tab", { name: /定位结果/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: /分析报告/ }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: /方案查询/ })); + fireEvent.click( + screen.getByRole("button", { name: "mock report action" }), + ); + + expect( + screen.getByText("report:selected-scheme:valve:none"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ })); + expect(screen.getByTestId("valve-scope")).toHaveTextContent( + "valve scope:selected-scheme:P-1,P-2", + ); + }); + + it("does not reuse valve results across schemes with the same pipe", () => { + render(<BurstPipeAnalysisPanel />); + + fireEvent.click(screen.getByRole("tab", { name: /方案查询/ })); + fireEvent.click( + screen.getByRole("button", { name: "mock report action" }), + ); + fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ })); + fireEvent.click( + screen.getByRole("button", { name: "mock valve result" }), + ); + + fireEvent.click(screen.getByRole("tab", { name: /方案查询/ })); + fireEvent.click( + screen.getByRole("button", { name: "mock other report action" }), + ); + + expect( + screen.getByText("report:other-scheme:valve:none"), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx index f305a34..3481f95 100644 --- a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx +++ b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx @@ -15,7 +15,7 @@ import { ChevronLeft, Analytics as AnalyticsIcon, Search as SearchIcon, - MyLocation as MyLocationIcon, + DescriptionOutlined as ReportIcon, Handyman as HandymanIcon, } from "@mui/icons-material"; import AnalysisParameters, { @@ -26,15 +26,12 @@ import SchemeQuery, { createBurstSchemeQueryState, type BurstSchemeQueryState, } from "./SchemeQuery"; -import LocationResults from "./LocationResults"; +import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport"; import ValveIsolation, { createValveIsolationState, type ValveIsolationState, } from "./ValveIsolation"; -import { api } from "@/lib/api"; -import { config } from "@config/config"; -import { useNotification } from "@refinedev/core"; -import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types"; +import { SchemeRecord, ValveIsolationResult } from "./types"; interface TabPanelProps { children?: React.ReactNode; @@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ // 持久化方案查询结果 const [schemes, setSchemes] = useState<SchemeRecord[]>([]); - // 定位结果数据 - const [locationResults, setLocationResults] = useState<LocationResult[]>([]); + const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null); + const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null); // 关阀分析结果和加载状态 const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false); const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null); + const [valveAnalysisSchemeName, setValveAnalysisSchemeName] = + useState<string | null>(null); const [valveIsolationState, setValveIsolationState] = useState<ValveIsolationState>(createValveIsolationState); - const { open } = useNotification(); - // 使用受控或非受控状态 const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; const handleToggle = () => { @@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ setCurrentTab(newValue); }; - const handleLocateScheme = async (scheme: SchemeRecord) => { - try { - const response = await api.get( - `${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`, - ); - setLocationResults(response.data); - setCurrentTab(2); // 切换到定位结果标签页 - } catch (error) { - console.error("获取定位结果失败:", error); - open?.({ - type: "error", - message: "获取定位结果失败", - description: "无法从服务器获取该方案的定位结果", - }); + const handleViewReport = (scheme: SchemeRecord) => { + if ( + valveAnalysisSchemeName !== scheme.schemeName || + !matchesValveAnalysis(scheme, valveAnalysisResult) + ) { + setValveAnalysisResult(null); + setValveAnalysisSchemeName(null); + setValveIsolationState(createValveIsolationState()); } + setReportScheme(scheme); + setReportGeneratedAt(new Date()); + setCurrentTab(2); + }; + + const handleValveAnalysisResultChange = ( + result: ValveIsolationResult | null, + ) => { + setValveAnalysisResult(result); + setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null); }; const drawerWidth = 520; @@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ label="方案查询" /> <Tab - icon={<MyLocationIcon fontSize="small" />} + icon={<ReportIcon fontSize="small" />} iconPosition="start" - label="定位结果" + label="分析报告" /> <Tab icon={<HandymanIcon fontSize="small" />} @@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ <SchemeQuery schemes={schemes} onSchemesChange={setSchemes} - onLocate={handleLocateScheme} + onViewReport={handleViewReport} state={queryState} onStateChange={setQueryState} /> </TabPanel> <TabPanel value={currentTab} index={2}> - <LocationResults - results={locationResults} + <AnalysisReport + scheme={reportScheme} + valveResult={valveAnalysisResult} + disabledValves={valveIsolationState.disabledValves} + generatedAt={reportGeneratedAt} /> </TabPanel> @@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({ loading={valveAnalysisLoading} result={valveAnalysisResult} onLoadingChange={setValveAnalysisLoading} - onResultChange={setValveAnalysisResult} + onResultChange={handleValveAnalysisResultChange} state={valveIsolationState} onStateChange={setValveIsolationState} + allowedPipeIds={ + reportScheme?.schemeDetail?.burst_ID + } + sourceSchemeName={reportScheme?.schemeName} /> </TabPanel> </Box> diff --git a/src/components/olmap/BurstSimulation/LocationResults.tsx b/src/components/olmap/BurstSimulation/LocationResults.tsx deleted file mode 100644 index 9f14001..0000000 --- a/src/components/olmap/BurstSimulation/LocationResults.tsx +++ /dev/null @@ -1,416 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useRef } from "react"; -import { - Box, - Typography, - Chip, - IconButton, - Tooltip, - Link, -} from "@mui/material"; -import { - LocationOn as LocationIcon, -} from "@mui/icons-material"; -import { queryFeaturesByIds } from "@/utils/mapQueryService"; -import { useMap } from "@components/olmap/core/MapComponent"; -import { GeoJSON } from "ol/format"; -import VectorLayer from "ol/layer/Vector"; -import VectorSource from "ol/source/Vector"; -import { Stroke, Style, Icon } from "ol/style"; -import Feature, { FeatureLike } from "ol/Feature"; -import { - along, - lineString, - length, - toMercator, - bbox, - featureCollection, -} from "@turf/turf"; -import { Point } from "ol/geom"; -import { toLonLat } from "ol/proj"; -import moment from "moment"; -import "moment-timezone"; -import { LocationResult } from "./types"; -import { FLOW_DISPLAY_UNIT } from "@utils/units"; - -interface LocationResultsProps { - results?: LocationResult[]; -} - -const LocationResults: React.FC<LocationResultsProps> = ({ - results = [], -}) => { - const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null); - const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); - const map = useMap(); - - // 格式化时间为 UTC+8 - const formatTime = (timeStr: string) => { - return moment(timeStr).utcOffset(8).format("YYYY-MM-DD HH:mm:ss"); - }; - - const handleLocatePipes = (pipeIds: string[]) => { - if (pipeIds.length > 0) { - queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => { - if (features.length > 0) { - // 设置高亮要素 - setHighlightFeatures(features); - // 将 OpenLayers Feature 转换为 GeoJSON Feature - const geojsonFormat = new GeoJSON(); - const geojsonFeatures = features.map((feature) => - geojsonFormat.writeFeatureObject(feature), - ); - - const extent = bbox(featureCollection(geojsonFeatures as any)); - - if (extent) { - map?.getView().fit(extent, { maxZoom: 18, duration: 1000 }); - } - } - }); - } - }; - - // 初始化管道图层和高亮图层 - useEffect(() => { - if (!map) return; - - const burstPipeStyle = function (feature: FeatureLike) { - const styles = []; - // 线条样式(底层发光,主线条,内层高亮线) - styles.push( - new Style({ - stroke: new Stroke({ - color: "rgba(255, 0, 0, 0.3)", - width: 12, - }), - }), - new Style({ - stroke: new Stroke({ - color: "rgba(255, 0, 0, 1)", - width: 6, - lineDash: [15, 10], - }), - }), - new Style({ - stroke: new Stroke({ - color: "rgba(255, 102, 102, 1)", - width: 3, - lineDash: [15, 10], - }), - }), - ); - const geometry = feature.getGeometry(); - const lineCoords = - geometry?.getType() === "LineString" - ? (geometry as any).getCoordinates() - : null; - if (geometry && lineCoords) { - const lineCoordsWGS84 = lineCoords.map((coord: []) => { - const [lon, lat] = toLonLat(coord); - return [lon, lat]; - }); - // 计算中点 - const lineStringFeature = lineString(lineCoordsWGS84); - const lineLength = length(lineStringFeature); - const midPoint = along(lineStringFeature, lineLength / 2).geometry - .coordinates; - // 在中点添加 icon 样式 - const midPointMercator = toMercator(midPoint); - styles.push( - new Style({ - geometry: new Point(midPointMercator), - image: new Icon({ - src: "/icons/burst_pipe.svg", - scale: 0.2, - anchor: [0.5, 1], - }), - }), - ); - } - return styles; - }; - // 创建高亮图层 - const highlightLayer = new VectorLayer({ - source: new VectorSource(), - style: burstPipeStyle, - maxZoom: 24, - minZoom: 12, - properties: { - name: "爆管管段高亮", - value: "burst_pipe_highlight", - queryable: false, - }, - }); - - map.addLayer(highlightLayer); - highlightLayerRef.current = highlightLayer; - - return () => { - highlightLayerRef.current = null; - map.removeLayer(highlightLayer); - }; - }, [map]); - - // 高亮要素的函数 - useEffect(() => { - const source = highlightLayerRef.current?.getSource(); - if (!source) { - return; - } - // 清除之前的高亮 - source.clear(); - // 添加新的高亮要素 - highlightFeatures.forEach((feature) => { - if (feature instanceof Feature) { - source.addFeature(feature); - } - }); - }, [highlightFeatures]); - - // 取第一条记录或空对象 - const result = results.length > 0 ? results[0] : null; - - return ( - <Box className="flex flex-col h-full"> - {/* 结果展示 */} - <Box className="flex-1 overflow-auto bg-white rounded border border-gray-200"> - {!result ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <circle - cx="40" - cy="40" - r="25" - stroke="currentColor" - strokeWidth="2" - /> - <circle cx="40" cy="40" r="5" fill="currentColor" /> - <line - x1="40" - y1="15" - x2="40" - y2="25" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="40" - y1="55" - x2="40" - y2="65" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="15" - y1="40" - x2="25" - y2="40" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="55" - y1="40" - x2="65" - y2="40" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">暂无定位结果</Typography> - <Typography variant="body2" className="mt-1"> - 请先执行方案分析 - </Typography> - </Box> - ) : ( - <Box className="p-5 h-full overflow-auto"> - {/* 头部:标识信息 */} - <Box className="mb-5"> - <Box className="flex items-center gap-2 mb-1"> - <Typography - variant="h6" - className="font-bold text-gray-900" - title={result.burst_incident} - > - {result.burst_incident} - </Typography> - <Chip - label={ - result.type === "burst_analysis" ? "爆管模拟" : result.type - } - size="small" - color="primary" - variant="outlined" - sx={{ - fontWeight: 600, - fontSize: "0.75rem", - height: "24px", - }} - /> - </Box> - <Typography variant="caption" className="text-gray-500"> - ID: {result.id} - </Typography> - </Box> - - {/* 主要信息:三栏卡片布局 */} - <Box className="grid grid-cols-3 gap-3 mb-5"> - {/* 检测时间卡片 */} - <Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm hover:shadow-md transition-shadow"> - <Box className="flex items-center gap-1.5 mb-2"> - <Box className="w-1.5 h-1.5 rounded-full bg-blue-600"></Box> - <Typography - variant="caption" - className="text-blue-700 font-semibold uppercase tracking-wide" - sx={{ fontSize: "0.7rem" }} - > - 检测时间 - </Typography> - </Box> - <Typography - variant="body2" - className="font-bold text-blue-900 leading-tight" - sx={{ fontSize: "0.875rem" }} - > - {formatTime(result.detect_time)} - </Typography> - </Box> - - {/* 漏损量卡片 */} - <Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm hover:shadow-md transition-shadow"> - <Box className="flex items-center gap-1.5 mb-2"> - <Box className="w-1.5 h-1.5 rounded-full bg-orange-600"></Box> - <Typography - variant="caption" - className="text-orange-700 font-semibold uppercase tracking-wide" - sx={{ fontSize: "0.7rem" }} - > - 漏损量 - </Typography> - </Box> - <Typography - variant="body2" - className="font-bold text-orange-900" - sx={{ fontSize: "0.875rem" }} - > - {result.leakage !== null - ? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}` - : "N/A"} - </Typography> - </Box> - - {/* 定位管段数量卡片 */} - <Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm hover:shadow-md transition-shadow"> - <Box className="flex items-center gap-1.5 mb-2"> - <Box className="w-1.5 h-1.5 rounded-full bg-green-600"></Box> - <Typography - variant="caption" - className="text-green-700 font-semibold uppercase tracking-wide" - sx={{ fontSize: "0.7rem" }} - > - 定位管段 - </Typography> - </Box> - <Typography - variant="body2" - className="font-bold text-green-900" - sx={{ fontSize: "0.875rem" }} - > - {result.locate_result ? result.locate_result.length : 0}{" "} - 个管段 - </Typography> - </Box> - </Box> - - {/* 定位管段详细列表 */} - {result.locate_result && result.locate_result.length > 0 && ( - <Box className="bg-white rounded-lg p-4 border-2 border-blue-200 shadow-sm"> - <Box className="flex items-center justify-between mb-3"> - <Typography - variant="body1" - className="text-gray-900 font-bold" - sx={{ fontSize: "0.95rem" }} - > - 管段列表 - </Typography> - <Box className="flex items-center gap-2"> - <Tooltip title="定位所有管道"> - <IconButton - size="small" - onClick={() => handleLocatePipes(result.locate_result!)} - color="primary" - sx={{ - backgroundColor: "rgba(37, 125, 212, 0.1)", - "&:hover": { - backgroundColor: "rgba(37, 125, 212, 0.2)", - }, - }} - > - <LocationIcon sx={{ fontSize: "1.2rem" }} /> - </IconButton> - </Tooltip> - </Box> - </Box> - <Box className="grid grid-cols-2 gap-2"> - {result.locate_result.map((pipeId, idx) => ( - <Box - key={idx} - className="bg-gradient-to-r from-blue-50 to-white rounded-lg px-3 py-2 border border-blue-200 hover:border-blue-400 hover:shadow-md transition-all cursor-pointer group" - onClick={() => handleLocatePipes([pipeId])} - sx={{ - "&:active": { - transform: "scale(0.98)", - boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)", - }, - }} - > - <Box className="flex items-center justify-between"> - <Typography - variant="body2" - className="font-semibold text-blue-700 group-hover:text-blue-900" - > - {pipeId} - </Typography> - <Box className="flex items-center gap-1"> - {/* <Tooltip title="定位管段"> - <IconButton - size="small" - onClick={(e) => { - e.stopPropagation(); - handleLocatePipes([pipeId]); - }} - sx={{ - "&:hover": { - backgroundColor: "rgba(37, 125, 212, 0.1)", - }, - }} - > - <LocationIcon sx={{ fontSize: "1rem" }} /> - </IconButton> - </Tooltip> */} - </Box> - </Box> - </Box> - ))} - </Box> - </Box> - )} - </Box> - )} - </Box> - </Box> - ); -}; - -export default LocationResults; diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index fa38b0e..734ddef 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -18,6 +18,7 @@ import { Link, } from "@mui/material"; import { + DescriptionOutlined as ReportIcon, Info as InfoIcon, LocationOn as LocationIcon, } from "@mui/icons-material"; @@ -59,7 +60,7 @@ import { interface SchemeQueryProps { schemes?: SchemeRecord[]; onSchemesChange?: (schemes: SchemeRecord[]) => void; - onLocate?: (scheme: SchemeRecord) => void; + onViewReport?: (scheme: SchemeRecord) => void; network?: string; state?: BurstSchemeQueryState; onStateChange?: (state: BurstSchemeQueryState) => void; @@ -88,7 +89,7 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemes: externalSchemes, onSchemesChange, - onLocate, + onViewReport, network = NETWORK_NAME, state, onStateChange, @@ -599,14 +600,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <InfoIcon fontSize="small" /> </IconButton> </Tooltip> - <Tooltip title="查看定位结果"> + <Tooltip title="查看分析报告"> <IconButton size="small" - onClick={() => onLocate?.(scheme)} + onClick={() => onViewReport?.(scheme)} color="primary" className="p-1" > - <LocationIcon fontSize="small" /> + <ReportIcon fontSize="small" /> </IconButton> </Tooltip> </Box> diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index 93f80d0..19ff8db 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -52,9 +52,12 @@ import { import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { isAllowedAccidentPipe } from "./valveIsolationScope"; interface ValveIsolationProps { initialPipeIds?: string[]; + allowedPipeIds?: string[]; + sourceSchemeName?: string; shouldFetch?: boolean; onFetchComplete?: () => void; loading?: boolean; @@ -81,6 +84,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({ const ValveIsolation: React.FC<ValveIsolationProps> = ({ initialPipeIds = [], + allowedPipeIds, + sourceSchemeName, shouldFetch = false, onFetchComplete, loading: externalLoading, @@ -160,14 +165,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ return; } - setSelectedPipeId(pipeId); + const normalizedPipeId = String(pipeId); + if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) { + open?.({ + type: "error", + message: "请选择当前爆管方案中的事故管段", + }); + return; + } + + setSelectedPipeId(normalizedPipeId); setHighlightFeature(feature); setIsSelecting(false); setResult(null); // 清除旧结果 } } }, - [isSelecting, map, open, setResult, setSelectedPipeId], + [ + allowedPipeIds, + isSelecting, + map, + open, + setResult, + setSelectedPipeId, + ], ); useEffect(() => { @@ -207,6 +228,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ "must_close" | "optional" | "affected_node" | "pipe" >("affected_node"); + const selectSchemeAccidentPipe = (pipeId: string) => { + if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return; + setSelectedPipeId(pipeId); + setHighlightFeature(null); + setHighlightFeatures([]); + setResult(null); + setActiveStep(0); + setExpandedResult(true); + setDisabledValves([]); + }; const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => { if (pipeIds.length > 0) { @@ -311,6 +342,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ open?.({ type: "error", message: "请在地图上选择要分析的管段" }); return; } + if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) { + open?.({ + type: "error", + message: "关阀分析仅限当前爆管方案中的事故管段", + }); + return; + } setLoading(true); const isExpandSearch = disabled.length > 0; if (!isExpandSearch) { @@ -352,7 +390,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ setLoading(false); } }, - [open, setActiveStep, setDisabledValves, setLoading, setResult], + [ + allowedPipeIds, + open, + setActiveStep, + setDisabledValves, + setLoading, + setResult, + ], ); // 监听外部传入的分析请求 @@ -912,6 +957,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ </StepLabel> <StepContent> <Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3"> + {allowedPipeIds !== undefined ? ( + <Alert severity="info" variant="outlined"> + 已绑定爆管方案“{sourceSchemeName || "未命名方案"}”。关阀分析仅限该方案中的事故管段,分析结果将写入对应报告。 + </Alert> + ) : ( + <Alert severity="warning" variant="outlined"> + 当前为独立关阀分析,结果不会自动写入爆管分析报告。请先从“方案查询”打开对应分析报告以建立关联。 + </Alert> + )} + {/* 选择管段 */} <Paper elevation={0} className="p-3 bg-white border border-gray-200"> <Box className="flex items-center justify-between mb-2"> @@ -955,6 +1010,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({ )} </Box> + {allowedPipeIds !== undefined && ( + <Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2"> + <Box className="mb-2 flex flex-wrap gap-1.5"> + {allowedPipeIds.map((pipeId) => ( + <Chip + key={pipeId} + label={pipeId} + size="small" + color={selectedPipeId === pipeId ? "primary" : "default"} + variant={selectedPipeId === pipeId ? "filled" : "outlined"} + onClick={() => selectSchemeAccidentPipe(pipeId)} + /> + ))} + </Box> + <Button + variant="outlined" + size="small" + fullWidth + disabled={allowedPipeIds.length === 0} + onClick={() => + allowedPipeIds[0] && + selectSchemeAccidentPipe(allowedPipeIds[0]) + } + startIcon={<CheckCircleIcon />} + > + 选择当前爆管管段 + </Button> + </Box> + )} + {isSelecting && ( <Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700"> 💡 点击地图上的管道添加爆管点 diff --git a/src/components/olmap/BurstSimulation/types.ts b/src/components/olmap/BurstSimulation/types.ts index 9d8f5d7..91abb26 100644 --- a/src/components/olmap/BurstSimulation/types.ts +++ b/src/components/olmap/BurstSimulation/types.ts @@ -28,15 +28,6 @@ export interface SchemaItem { scheme_detail?: SchemeDetail; } -export interface LocationResult { - id: number; - type: string; - burst_incident: string; - leakage: number | null; - detect_time: string; - locate_result: string[] | null; -} - export interface ValveIsolationResult { accident_elements: string[]; affected_nodes: string[]; diff --git a/src/components/olmap/BurstSimulation/valveIsolationScope.ts b/src/components/olmap/BurstSimulation/valveIsolationScope.ts new file mode 100644 index 0000000..9c8c14c --- /dev/null +++ b/src/components/olmap/BurstSimulation/valveIsolationScope.ts @@ -0,0 +1,4 @@ +export const isAllowedAccidentPipe = ( + pipeId: string, + allowedPipeIds: string[] | undefined, +) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId); -- 2.54.0 From 1d7e07174fd6a356209da3a78173c76224a6be24 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 13:21:55 +0800 Subject: [PATCH 248/281] fix(map): use component-level loading skeletons --- .../(map)/health-risk-analysis/loading.tsx | 5 - .../(map)/health-risk-analysis/page.tsx | 26 +- .../burst-detection/loading.tsx | 5 - .../burst-detection/page.tsx | 11 +- .../burst-location/loading.tsx | 5 - .../burst-location/page.tsx | 11 +- .../burst-simulation/loading.tsx | 5 - .../burst-simulation/page.tsx | 11 +- .../contaminant-simulation/loading.tsx | 5 - .../contaminant-simulation/page.tsx | 11 +- .../dma-leak-detection/loading.tsx | 5 - .../dma-leak-detection/page.tsx | 11 +- .../flushing-analysis/loading.tsx | 5 - .../flushing-analysis/page.tsx | 11 +- .../monitoring-place-optimization/loading.tsx | 5 - .../monitoring-place-optimization/page.tsx | 16 +- .../(map)/network-simulation/loading.tsx | 5 - .../(main)/(map)/network-simulation/page.tsx | 27 +- .../(map)/scada-data-cleaning/loading.tsx | 5 - .../(main)/(map)/scada-data-cleaning/page.tsx | 17 +- src/app/(main)/layout.tsx | 9 +- .../loading/MapComponentSkeletonLayouts.tsx | 524 ++++++++++++++++++ .../loading/MapComponentSkeletons.test.tsx | 149 +++++ .../loading/MapComponentSkeletons.tsx | 160 ++++++ src/components/loading/MapSkeleton.tsx | 191 ------- .../loading/mapComponentSkeletonConfig.ts | 120 ++++ .../olmap/core/Controls/Toolbar.tsx | 2 +- .../core/Controls/ToolbarHistoryPanel.tsx | 2 +- 28 files changed, 1095 insertions(+), 264 deletions(-) delete mode 100644 src/app/(main)/(map)/health-risk-analysis/loading.tsx delete mode 100644 src/app/(main)/(map)/hydraulic-simulation/burst-detection/loading.tsx delete mode 100644 src/app/(main)/(map)/hydraulic-simulation/burst-location/loading.tsx delete mode 100644 src/app/(main)/(map)/hydraulic-simulation/burst-simulation/loading.tsx delete mode 100644 src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/loading.tsx delete mode 100644 src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/loading.tsx delete mode 100644 src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/loading.tsx delete mode 100644 src/app/(main)/(map)/monitoring-place-optimization/loading.tsx delete mode 100644 src/app/(main)/(map)/network-simulation/loading.tsx delete mode 100644 src/app/(main)/(map)/scada-data-cleaning/loading.tsx create mode 100644 src/components/loading/MapComponentSkeletonLayouts.tsx create mode 100644 src/components/loading/MapComponentSkeletons.test.tsx create mode 100644 src/components/loading/MapComponentSkeletons.tsx delete mode 100644 src/components/loading/MapSkeleton.tsx create mode 100644 src/components/loading/mapComponentSkeletonConfig.ts diff --git a/src/app/(main)/(map)/health-risk-analysis/loading.tsx b/src/app/(main)/(map)/health-risk-analysis/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/health-risk-analysis/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/health-risk-analysis/page.tsx b/src/app/(main)/(map)/health-risk-analysis/page.tsx index 779e035..12bb1e2 100644 --- a/src/app/(main)/(map)/health-risk-analysis/page.tsx +++ b/src/app/(main)/(map)/health-risk-analysis/page.tsx @@ -1,10 +1,10 @@ "use client"; -import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext"; -import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"; -import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel"; import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; import { RAINBOW_COLORS, @@ -12,6 +12,26 @@ import { } from "@components/olmap/HealthRiskAnalysis/types"; import { Box } from "@mui/material"; +const Timeline = dynamic( + () => import("@components/olmap/HealthRiskAnalysis/Timeline"), + { + loading: () => <MapTimelineSkeleton />, + }, +); +const HealthRiskStatistics = dynamic( + () => + import("@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"), + { + loading: () => <MapPanelSkeleton variant="health-risk-analysis" />, + }, +); +const PredictDataPanel = dynamic( + () => import("@components/olmap/HealthRiskAnalysis/PredictDataPanel"), + { + loading: () => null, + }, +); + export default function Home() { return ( <HealthRiskProvider> diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-detection/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-detection/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/hydraulic-simulation/burst-detection/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx index ca5408c..91a87fa 100644 --- a/src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx +++ b/src/app/(main)/(map)/hydraulic-simulation/burst-detection/page.tsx @@ -1,7 +1,16 @@ "use client"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel"; + +const BurstDetectionPanel = dynamic( + () => import("@/components/olmap/BurstDetection/BurstDetectionPanel"), + { + loading: () => <MapPanelSkeleton variant="burst-detection" />, + }, +); export default function Home() { return ( diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-location/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-location/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/hydraulic-simulation/burst-location/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx index d5770b1..751a827 100644 --- a/src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx +++ b/src/app/(main)/(map)/hydraulic-simulation/burst-location/page.tsx @@ -1,7 +1,16 @@ "use client"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel"; + +const BurstLocationPanel = dynamic( + () => import("@/components/olmap/BurstLocation/BurstLocationPanel"), + { + loading: () => <MapPanelSkeleton variant="burst-location" />, + }, +); export default function Home() { return ( diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx index 699997f..1391ee3 100644 --- a/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx +++ b/src/app/(main)/(map)/hydraulic-simulation/burst-simulation/page.tsx @@ -1,7 +1,16 @@ "use client"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"; + +const BurstPipeAnalysisPanel = dynamic( + () => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"), + { + loading: () => <MapPanelSkeleton variant="burst-simulation" />, + }, +); export default function Home() { return ( diff --git a/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx index 6addcb6..c23ab33 100644 --- a/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx +++ b/src/app/(main)/(map)/hydraulic-simulation/contaminant-simulation/page.tsx @@ -1,7 +1,16 @@ "use client"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel"; + +const WaterQualityPanel = dynamic( + () => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"), + { + loading: () => <MapPanelSkeleton variant="contaminant-simulation" />, + }, +); export default function Home() { return ( diff --git a/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx index 39058bb..a11e9b1 100644 --- a/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx +++ b/src/app/(main)/(map)/hydraulic-simulation/dma-leak-detection/page.tsx @@ -1,7 +1,16 @@ "use client"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"; + +const DMALeakDetectionPanel = dynamic( + () => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"), + { + loading: () => <MapPanelSkeleton variant="dma-leak-detection" />, + }, +); export default function Home() { return ( diff --git a/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/loading.tsx b/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx b/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx index 19df843..067ea3e 100644 --- a/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx +++ b/src/app/(main)/(map)/hydraulic-simulation/flushing-analysis/page.tsx @@ -1,7 +1,16 @@ "use client"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"; + +const FlushingAnalysisPanel = dynamic( + () => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"), + { + loading: () => <MapPanelSkeleton variant="flushing-analysis" />, + }, +); export default function Home() { return ( diff --git a/src/app/(main)/(map)/monitoring-place-optimization/loading.tsx b/src/app/(main)/(map)/monitoring-place-optimization/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/monitoring-place-optimization/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/monitoring-place-optimization/page.tsx b/src/app/(main)/(map)/monitoring-place-optimization/page.tsx index 0272841..9339ef0 100644 --- a/src/app/(main)/(map)/monitoring-place-optimization/page.tsx +++ b/src/app/(main)/(map)/monitoring-place-optimization/page.tsx @@ -1,7 +1,21 @@ "use client"; +import dynamic from "next/dynamic"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"; + +const MonitoringPlaceOptimizationPanel = dynamic( + () => + import( + "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel" + ), + { + loading: () => ( + <MapPanelSkeleton variant="monitoring-place-optimization" /> + ), + }, +); export default function Home() { return ( diff --git a/src/app/(main)/(map)/network-simulation/loading.tsx b/src/app/(main)/(map)/network-simulation/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/network-simulation/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/network-simulation/page.tsx b/src/app/(main)/(map)/network-simulation/page.tsx index 705afe1..b85a1d8 100644 --- a/src/app/(main)/(map)/network-simulation/page.tsx +++ b/src/app/(main)/(map)/network-simulation/page.tsx @@ -1,11 +1,32 @@ "use client"; +import dynamic from "next/dynamic"; import { useCallback, useState } from "react"; -import Timeline from "@components/olmap/core/Controls/Timeline"; + +import { + MapPanelSkeleton, + MapTimelineSkeleton, +} from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList"; -import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel"; +const Timeline = dynamic( + () => import("@components/olmap/core/Controls/Timeline"), + { + loading: () => <MapTimelineSkeleton />, + }, +); +const SCADADeviceList = dynamic( + () => import("@components/olmap/SCADA/SCADADeviceList"), + { + loading: () => <MapPanelSkeleton variant="network-simulation" />, + }, +); +const SCADADataPanel = dynamic( + () => import("@components/olmap/SCADA/SCADADataPanel"), + { + loading: () => null, + }, +); export default function Home() { const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]); diff --git a/src/app/(main)/(map)/scada-data-cleaning/loading.tsx b/src/app/(main)/(map)/scada-data-cleaning/loading.tsx deleted file mode 100644 index 2c57921..0000000 --- a/src/app/(main)/(map)/scada-data-cleaning/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapSkeleton } from "@components/loading/MapSkeleton"; - -export default function Loading() { - return <MapSkeleton />; -} diff --git a/src/app/(main)/(map)/scada-data-cleaning/page.tsx b/src/app/(main)/(map)/scada-data-cleaning/page.tsx index 592a3b5..155b56e 100644 --- a/src/app/(main)/(map)/scada-data-cleaning/page.tsx +++ b/src/app/(main)/(map)/scada-data-cleaning/page.tsx @@ -1,10 +1,23 @@ "use client"; +import dynamic from "next/dynamic"; import { useCallback, useState } from "react"; + +import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons"; import MapToolbar from "@components/olmap/core/Controls/Toolbar"; -import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList"; -import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel"; +const SCADADeviceList = dynamic( + () => import("@components/olmap/SCADA/SCADADeviceList"), + { + loading: () => <MapPanelSkeleton variant="scada-data-cleaning" />, + }, +); +const SCADADataPanel = dynamic( + () => import("@components/olmap/SCADA/SCADADataPanel"), + { + loading: () => null, + }, +); export default function Home() { const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]); diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index ed82d39..b78c33a 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,11 +1,10 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; -import React, { Suspense } from "react"; +import type { ReactNode } from "react"; import authOptions from "@app/api/auth/[...nextauth]/options"; import { Header } from "@components/header"; import { Title } from "@components/title"; -import { MapSkeleton } from "@components/loading/MapSkeleton"; import { AppSider } from "@components/sider/AppSider"; import { ThemedLayout } from "@refinedev/mui"; import { getServerSession } from "next-auth/next"; @@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA; export default async function MainLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: ReactNode; }>) { const cookieStore = await cookies(); const theme = cookieStore.get("theme"); @@ -49,9 +48,7 @@ export default async function MainLayout({ sx: { height: "100vh", overflow: "hidden" }, }} > - <Suspense fallback={<MapSkeleton />}> - {children} - </Suspense> + {children} </ThemedLayout> ); } diff --git a/src/components/loading/MapComponentSkeletonLayouts.tsx b/src/components/loading/MapComponentSkeletonLayouts.tsx new file mode 100644 index 0000000..42f75d5 --- /dev/null +++ b/src/components/loading/MapComponentSkeletonLayouts.tsx @@ -0,0 +1,524 @@ +import { Box, Skeleton } from "@mui/material"; + +import type { MapSkeletonVariant } from "./mapComponentSkeletonConfig"; + +const skeletonSx = { + transform: "none", + bgcolor: "rgba(37, 125, 212, 0.10)", + "&::after": { + background: + "linear-gradient(90deg, transparent, rgba(37, 125, 212, 0.12), transparent)", + }, + "@media (prefers-reduced-motion: reduce)": { + animation: "none", + "&::after": { + animation: "none", + }, + }, +} as const; + +const Line = ({ width = "44%", height = 18 }: { width?: string; height?: number }) => ( + <Skeleton + variant="text" + animation="wave" + width={width} + height={height} + sx={skeletonSx} + /> +); + +const Field = () => ( + <Box sx={{ width: "100%" }}> + <Line width="34%" height={18} /> + <Skeleton + variant="rounded" + animation="wave" + height={40} + sx={{ ...skeletonSx, mt: 0.5, borderRadius: 1 }} + /> + </Box> +); + +const TwoColumns = ({ children }: { children: React.ReactNode }) => ( + <Box + sx={{ + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: 1.5, + }} + > + {children} + </Box> +); + +const Action = ({ + width = "100%", + height = 40, + testId, +}: { + width?: string; + height?: number; + testId?: string; +}) => ( + <Skeleton + data-testid={testId} + variant="rounded" + animation="wave" + width={width} + height={height} + sx={{ ...skeletonSx, borderRadius: 1 }} + /> +); + +const Notice = ({ testId }: { testId?: string }) => ( + <Box + data-testid={testId} + sx={{ + display: "grid", + gap: 0.5, + p: 1.5, + bgcolor: "rgba(37, 125, 212, 0.07)", + borderRadius: 1, + }} + > + <Line width="58%" height={18} /> + <Line width="88%" height={16} /> + </Box> +); + +const SelectionHeader = ({ actionWidth = "34%" }: { actionWidth?: string }) => ( + <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> + <Box sx={{ flex: 1 }}> + <Line width="44%" height={20} /> + </Box> + <Action width={actionWidth} height={32} /> + </Box> +); + +const EmptySelection = ({ height = 44 }: { height?: number }) => ( + <Skeleton + variant="rounded" + animation="wave" + height={height} + sx={{ ...skeletonSx, borderRadius: 1 }} + /> +); + +const Divider = () => ( + <Box sx={{ height: 1, flex: "0 0 auto", bgcolor: "rgba(15, 23, 42, 0.08)" }} /> +); + +const AdvancedRow = ({ testId }: { testId?: string }) => ( + <Box + data-testid={testId} + sx={{ + minHeight: 40, + display: "flex", + alignItems: "center", + gap: 1, + px: 1.25, + border: "1px solid", + borderColor: "rgba(15, 23, 42, 0.10)", + borderRadius: 1, + }} + > + <Box sx={{ flex: 1 }}> + <Line width="34%" height={18} /> + </Box> + <Skeleton + variant="circular" + animation="wave" + width={24} + height={24} + sx={skeletonSx} + /> + </Box> +); + +const SelectionList = ({ rows = 3 }: { rows?: number }) => ( + <Box + sx={{ + display: "grid", + gap: 1, + p: 1.25, + bgcolor: "rgba(15, 23, 42, 0.025)", + borderRadius: 1, + }} + > + {Array.from({ length: rows }, (_, index) => ( + <Box + key={index} + sx={{ display: "flex", alignItems: "center", gap: 1 }} + > + <Skeleton + variant="circular" + animation="wave" + width={26} + height={26} + sx={skeletonSx} + /> + <Box sx={{ flex: 1 }}> + <Line width={`${72 - index * 8}%`} height={16} /> + </Box> + <Skeleton + variant="rounded" + animation="wave" + width={48} + height={26} + sx={{ ...skeletonSx, borderRadius: 1 }} + /> + </Box> + ))} + </Box> +); + +const AnalysisFormSkeleton = ({ variant }: { variant: MapSkeletonVariant }) => { + switch (variant) { + case "burst-detection": + return ( + <> + <Box + data-testid="burst-detection-primary-fields" + data-layout="vertical" + sx={{ display: "grid", gap: 1.5 }} + > + <Field /> + <Field /> + <Field /> + </Box> + <Notice /> + <Line width="88%" height={16} /> + <Box sx={{ display: "flex", gap: 1.5, mt: "auto", pt: 1.5 }}> + <Action /> + <Action /> + </Box> + </> + ); + case "burst-location": + return ( + <> + <Box + data-testid="burst-location-primary-fields" + data-layout="vertical" + sx={{ display: "grid", gap: 1.5 }} + > + <Field /> + <Field /> + </Box> + <Notice /> + <TwoColumns> + <Field /> + <Field /> + </TwoColumns> + <Field /> + <AdvancedRow /> + <Box sx={{ mt: "auto", pt: 1.5 }}> + <Action /> + </Box> + </> + ); + case "burst-simulation": + return ( + <> + <Box + data-testid="burst-simulation-selection" + sx={{ display: "grid", gap: 1 }} + > + <SelectionHeader actionWidth="32%" /> + </Box> + <Box + data-testid="burst-simulation-primary-fields" + data-layout="vertical" + sx={{ display: "grid", gap: 1.5 }} + > + <Field /> + <Field /> + <Field /> + </Box> + <Box sx={{ mt: "auto", pt: 1.5 }}> + <Action /> + </Box> + </> + ); + case "contaminant-simulation": + return ( + <> + <Box + data-testid="contaminant-simulation-selection" + sx={{ display: "grid", gap: 1 }} + > + <SelectionHeader actionWidth="32%" /> + <EmptySelection /> + </Box> + <Box + data-testid="contaminant-simulation-primary-fields" + data-layout="vertical" + sx={{ display: "grid", gap: 1.5 }} + > + {Array.from({ length: 4 }, (_, index) => ( + <Field key={index} /> + ))} + </Box> + <Box sx={{ mt: "auto", pt: 1.5 }}> + <Action /> + </Box> + </> + ); + case "dma-leak-detection": + return ( + <> + <Notice testId="dma-notice-skeleton" /> + <Box + data-testid="dma-primary-fields" + data-layout="vertical" + sx={{ display: "grid", gap: 1.5 }} + > + {Array.from({ length: 5 }, (_, index) => ( + <Field key={index} /> + ))} + </Box> + <AdvancedRow testId="dma-advanced-skeleton" /> + <Box sx={{ mt: "auto", pt: 1.5 }}> + <Action testId="dma-primary-action-skeleton" /> + </Box> + </> + ); + case "flushing-analysis": + return ( + <> + <Box + data-testid="flushing-valve-selection" + sx={{ display: "grid", gap: 1 }} + > + <SelectionHeader actionWidth="32%" /> + <EmptySelection height={192} /> + </Box> + <Divider /> + <Box + data-testid="flushing-node-selection" + sx={{ display: "grid", gap: 1 }} + > + <SelectionHeader actionWidth="32%" /> + <EmptySelection height={48} /> + </Box> + <Divider /> + <Box + data-testid="flushing-primary-fields" + data-layout="vertical-vertical-two-columns" + sx={{ display: "grid", gap: 1.5 }} + > + <Field /> + <Field /> + <TwoColumns> + <Field /> + <Field /> + </TwoColumns> + </Box> + <Box sx={{ mt: "auto", pt: 1.5 }}> + <Action /> + </Box> + </> + ); + case "monitoring-place-optimization": + return ( + <> + <Box + data-testid="monitoring-primary-fields" + data-layout="vertical" + sx={{ display: "grid", gap: 2 }} + > + {Array.from({ length: 5 }, (_, index) => ( + <Field key={index} /> + ))} + </Box> + <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}> + <Action width="132px" /> + </Box> + </> + ); + default: + return null; + } +}; + +const DeviceListSkeleton = ({ cleaning }: { cleaning: boolean }) => ( + <Box + data-testid={cleaning ? "cleaning-device-content" : "network-device-content"} + sx={{ display: "grid", gap: 2 }} + > + <Skeleton + variant="rounded" + animation="wave" + height={40} + sx={{ ...skeletonSx, borderRadius: 1 }} + /> + <Box + sx={{ + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: 1, + }} + > + {Array.from({ length: 3 }, (_, index) => ( + <Skeleton + key={index} + variant="rounded" + animation="wave" + height={38} + sx={{ ...skeletonSx, borderRadius: 1 }} + /> + ))} + </Box> + <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> + <Box sx={{ flex: 1 }}> + <Line width="58%" height={16} /> + </Box> + {cleaning && ( + <Skeleton + data-testid="cleaning-action-skeleton" + variant="circular" + animation="wave" + width={32} + height={32} + sx={skeletonSx} + /> + )} + <Skeleton + variant="circular" + animation="wave" + width={32} + height={32} + sx={skeletonSx} + /> + </Box> + <Box sx={{ height: 1, bgcolor: "rgba(15, 23, 42, 0.08)" }} /> + <SelectionList rows={6} /> + </Box> +); + +const RiskChartSkeleton = () => ( + <Box + data-testid="health-risk-chart-content" + sx={{ display: "grid", height: "100%", minHeight: 360, gap: 2 }} + > + <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> + <Line width="32%" height={18} /> + <Box sx={{ flex: 1 }} /> + <Action width="22%" /> + </Box> + <Box + sx={{ + position: "relative", + display: "flex", + alignItems: "flex-end", + justifyContent: "space-around", + gap: 2, + minHeight: 300, + px: 3, + pt: 3, + pb: 2, + bgcolor: "rgba(15, 23, 42, 0.025)", + borderRadius: 1, + boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.10)", + }} + > + {[36, 58, 44, 76, 62, 84].map((height, index) => ( + <Skeleton + key={index} + variant="rounded" + animation="wave" + width="9%" + height={`${height}%`} + sx={{ ...skeletonSx, borderRadius: "6px 6px 2px 2px" }} + /> + ))} + </Box> + </Box> +); + +export function MapPageSkeletonContent({ + variant, +}: { + variant: MapSkeletonVariant; +}) { + if (variant === "network-simulation") { + return <DeviceListSkeleton cleaning={false} />; + } + + if (variant === "scada-data-cleaning") { + return <DeviceListSkeleton cleaning />; + } + + if (variant === "health-risk-analysis") { + return <RiskChartSkeleton />; + } + + return ( + <Box + data-testid={`${variant}-content`} + sx={{ + display: "flex", + flexDirection: "column", + gap: 2, + height: "100%", + minHeight: 0, + }} + > + <AnalysisFormSkeleton variant={variant} /> + </Box> + ); +} + +export function MapTimelineSkeleton() { + return ( + <Box + role="status" + aria-live="polite" + aria-label="正在加载时间轴" + data-component-skeleton="map-timeline" + data-testid="map-timeline-skeleton" + sx={{ + position: "absolute", + left: "50%", + bottom: 16, + width: "min(950px, calc(100% - 32px))", + transform: "translateX(-50%)", + display: "grid", + gap: 1.5, + p: 2, + bgcolor: "rgba(255, 255, 255, 0.96)", + borderRadius: 1.5, + boxShadow: + "0 14px 30px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08)", + pointerEvents: "none", + }} + > + <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> + {Array.from({ length: 5 }, (_, index) => ( + <Skeleton + key={index} + variant="circular" + animation="wave" + width={32} + height={32} + sx={skeletonSx} + /> + ))} + <Box sx={{ flex: 1 }} /> + <Skeleton + variant="rounded" + animation="wave" + width={132} + height={32} + sx={{ ...skeletonSx, borderRadius: 1 }} + /> + </Box> + <Skeleton + variant="rounded" + animation="wave" + height={8} + sx={{ ...skeletonSx, borderRadius: 999 }} + /> + </Box> + ); +} diff --git a/src/components/loading/MapComponentSkeletons.test.tsx b/src/components/loading/MapComponentSkeletons.test.tsx new file mode 100644 index 0000000..ab7535a --- /dev/null +++ b/src/components/loading/MapComponentSkeletons.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, within } from "@testing-library/react"; + +import { + MapPanelSkeleton, + MapTimelineSkeleton, +} from "./MapComponentSkeletons"; +import { + MAP_SKELETON_CONFIGS, + MAP_SKELETON_VARIANTS, + type MapSkeletonVariant, +} from "./mapComponentSkeletonConfig"; + +describe("map component skeletons", () => { + it("keeps the loading state inside the analysis panel geometry", () => { + render(<MapPanelSkeleton variant="burst-simulation" />); + + const panel = screen.getByRole("status", { + name: "正在加载爆管分析", + }); + + expect(panel).toHaveAttribute("data-component-skeleton", "map-panel"); + expect(panel).toHaveAttribute("data-panel-side", "right"); + expect(panel).toHaveAttribute("data-panel-width", "520"); + expect(panel).toHaveStyle({ + position: "absolute", + pointerEvents: "none", + }); + expect(screen.getByTestId("burst-simulation-content")).toBeInTheDocument(); + }); + + it("keeps an explicit panel configuration for every heavy map component", () => { + expect(Object.keys(MAP_SKELETON_CONFIGS).sort()).toEqual( + [...MAP_SKELETON_VARIANTS].sort(), + ); + }); + + it("keeps known panel chrome out of the skeleton layer", () => { + render(<MapPanelSkeleton variant="burst-simulation" />); + + const header = screen.getByTestId("map-panel-header"); + const tabs = screen.getByTestId("map-panel-tabs"); + + expect(header.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument(); + expect(tabs.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument(); + expect(screen.queryByText("正在加载组件")).not.toBeInTheDocument(); + expect( + within(tabs).getByText("分析要件"), + ).toBeInTheDocument(); + }); + + it.each(MAP_SKELETON_VARIANTS)( + "renders the component-specific panel for %s", + (variant: MapSkeletonVariant) => { + const config = MAP_SKELETON_CONFIGS[variant]; + + render(<MapPanelSkeleton variant={variant} />); + + const panel = screen.getByRole("status", { + name: `正在加载${config.title}`, + }); + expect(panel).toHaveAttribute("data-component-skeleton", "map-panel"); + expect(panel).toHaveAttribute("data-panel-side", config.side); + expect(panel).toHaveAttribute( + "data-panel-width", + String(config.panelWidth), + ); + expect(screen.getByText(config.panelTitle)).toBeInTheDocument(); + expect(screen.queryAllByTestId("map-panel-tab-label")).toHaveLength( + config.tabLabels.length, + ); + }, + ); + + it("distinguishes cleaning controls from the simulation device list", () => { + const { rerender } = render( + <MapPanelSkeleton variant="network-simulation" />, + ); + + expect(screen.getByTestId("network-device-content")).toBeInTheDocument(); + + rerender(<MapPanelSkeleton variant="scada-data-cleaning" />); + + expect(screen.getByTestId("cleaning-device-content")).toBeInTheDocument(); + expect(screen.getByTestId("cleaning-action-skeleton")).toBeInTheDocument(); + }); + + it("matches the DMA parameter panel's vertical control distribution", () => { + render(<MapPanelSkeleton variant="dma-leak-detection" />); + + const fields = screen.getByTestId("dma-primary-fields"); + + expect(screen.getByTestId("dma-notice-skeleton")).toBeInTheDocument(); + expect(fields).toHaveAttribute("data-layout", "vertical"); + expect(fields.children).toHaveLength(5); + expect(screen.getByTestId("dma-advanced-skeleton")).toBeInTheDocument(); + expect(screen.getByTestId("dma-primary-action-skeleton")).toHaveStyle({ + width: "100%", + }); + }); + + it.each([ + ["burst-detection", "burst-detection-primary-fields", "vertical", 3], + ["burst-location", "burst-location-primary-fields", "vertical", 2], + ["burst-simulation", "burst-simulation-primary-fields", "vertical", 3], + [ + "contaminant-simulation", + "contaminant-simulation-primary-fields", + "vertical", + 4, + ], + [ + "flushing-analysis", + "flushing-primary-fields", + "vertical-vertical-two-columns", + 3, + ], + [ + "monitoring-place-optimization", + "monitoring-primary-fields", + "vertical", + 5, + ], + ] as const)( + "matches the primary control distribution for %s", + (variant, testId, layout, childCount) => { + render(<MapPanelSkeleton variant={variant} />); + + const fields = screen.getByTestId(testId); + + expect(fields).toHaveAttribute("data-layout", layout); + expect(fields.children).toHaveLength(childCount); + }, + ); + + it("keeps the two flushing selection regions separate", () => { + render(<MapPanelSkeleton variant="flushing-analysis" />); + + expect(screen.getByTestId("flushing-valve-selection")).toBeInTheDocument(); + expect(screen.getByTestId("flushing-node-selection")).toBeInTheDocument(); + }); + + it("renders the timeline as an independent loading boundary", () => { + render(<MapTimelineSkeleton />); + + expect( + screen.getByRole("status", { name: "正在加载时间轴" }), + ).toHaveAttribute("data-component-skeleton", "map-timeline"); + }); +}); diff --git a/src/components/loading/MapComponentSkeletons.tsx b/src/components/loading/MapComponentSkeletons.tsx new file mode 100644 index 0000000..9b29f16 --- /dev/null +++ b/src/components/loading/MapComponentSkeletons.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { Box, CircularProgress, Typography } from "@mui/material"; + +import { MapPageSkeletonContent } from "./MapComponentSkeletonLayouts"; +import { + MAP_SKELETON_CONFIGS, + type MapSkeletonVariant, +} from "./mapComponentSkeletonConfig"; + +export { MapTimelineSkeleton } from "./MapComponentSkeletonLayouts"; + +export interface MapPanelSkeletonProps { + variant: MapSkeletonVariant; +} + +/** + * 地图业务面板的组件级加载占位。 + * 只占据最终面板区域,不替换地图、工具栏或其他已经完成加载的组件。 + */ +export function MapPanelSkeleton({ variant }: MapPanelSkeletonProps) { + const config = MAP_SKELETON_CONFIGS[variant]; + const horizontalPosition = + config.side === "left" + ? { left: { xs: 12, md: 16 }, right: "auto" } + : { right: { xs: 12, md: 16 }, left: "auto" }; + + return ( + <Box + role="status" + aria-live="polite" + aria-label={`正在加载${config.title}`} + data-component-skeleton="map-panel" + data-testid="map-panel-skeleton" + data-panel-side={config.side} + data-panel-width={config.panelWidth} + sx={{ + position: "absolute", + top: { xs: 12, md: config.panelTop }, + width: { + xs: "calc(100% - 24px)", + sm: `min(${Math.min(config.panelWidth, 640)}px, calc(100% - 32px))`, + md: config.panelWidth, + }, + height: { + xs: "calc(100% - 24px)", + md: `min(${config.panelMaxHeight}px, calc(100% - ${config.panelTop + 16}px))`, + }, + zIndex: 1300, + display: "flex", + flexDirection: "column", + overflow: "hidden", + bgcolor: "rgba(255, 255, 255, 0.97)", + borderRadius: 1.5, + boxShadow: + "0 20px 34px rgba(15, 23, 42, 0.18), 0 4px 10px rgba(15, 23, 42, 0.08)", + pointerEvents: "none", + ...horizontalPosition, + }} + > + <Box + data-testid="map-panel-header" + sx={{ + minHeight: 64, + display: "flex", + alignItems: "center", + gap: 1.25, + px: 2.5, + py: 1.5, + bgcolor: "#257DD4", + color: "#fff", + }} + > + <Box sx={{ flex: 1, minWidth: 0 }}> + <Typography + component="span" + lang="zh-CN" + sx={{ + display: "block", + fontSize: 18, + fontWeight: 600, + lineHeight: 1.4, + }} + > + {config.panelTitle} + </Typography> + </Box> + <CircularProgress + aria-label={`正在加载${config.title}`} + size={20} + thickness={4} + sx={{ color: "rgba(255, 255, 255, 0.90)" }} + /> + </Box> + + {config.tabLabels.length > 0 && ( + <Box + data-testid="map-panel-tabs" + sx={{ + minHeight: 48, + display: "grid", + gridTemplateColumns: `repeat(${config.tabLabels.length}, minmax(0, 1fr))`, + alignItems: "center", + gap: 1, + px: 2, + bgcolor: "#fff", + boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.08)", + }} + > + {config.tabLabels.map((label, index) => ( + <Box + key={label} + data-testid="map-panel-tab-label" + sx={{ display: "grid", justifyItems: "center", gap: 0.5 }} + > + <Typography + component="span" + lang="zh-CN" + sx={{ + fontSize: 14, + fontWeight: 500, + lineHeight: 1.5, + color: index === 0 ? "#257DD4" : "text.secondary", + }} + > + {label} + </Typography> + {index === 0 && ( + <Box + sx={{ + width: "78%", + height: 2, + bgcolor: "rgba(37, 125, 212, 0.34)", + borderRadius: 999, + }} + /> + )} + </Box> + ))} + </Box> + )} + + <Box + sx={{ + flex: 1, + minHeight: 0, + overflow: "hidden", + p: variant === "health-risk-analysis" ? 2 : 2.5, + bgcolor: + variant === "network-simulation" || + variant === "scada-data-cleaning" + ? "rgba(248, 250, 252, 0.98)" + : "#fff", + }} + > + <MapPageSkeletonContent variant={variant} /> + </Box> + </Box> + ); +} diff --git a/src/components/loading/MapSkeleton.tsx b/src/components/loading/MapSkeleton.tsx deleted file mode 100644 index 755d1ca..0000000 --- a/src/components/loading/MapSkeleton.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { Box, Skeleton, CircularProgress } from "@mui/material"; - -/** - * 地图页面骨架屏组件 - * 提供即时视觉反馈,模拟地图界面布局 - */ -export function MapSkeleton() { - return ( - <Box - sx={{ - width: "100%", - height: "100%", - position: "relative", - bgcolor: "background.default", - overflow: "hidden", - }} - > - {/* 主地图区域骨架 */} - <Skeleton - variant="rectangular" - animation="wave" - sx={{ - width: "100%", - height: "100%", - bgcolor: "action.hover", - }} - /> - - {/* 中央加载指示器 */} - <Box - sx={{ - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - zIndex: 10, - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: 2, - }} - > - <CircularProgress size={48} thickness={4} color="primary" /> - </Box> - - {/* 左侧工具栏骨架 (垂直) */} - <Box - sx={{ - position: "absolute", - top: 20, - left: 20, - display: "flex", - flexDirection: "column", - gap: 1.5, - zIndex: 5, - }} - > - {[1, 2, 3, 4].map((i) => ( - <Skeleton - key={i} - variant="circular" - width={40} - height={40} - animation="wave" - sx={{ boxShadow: 1 }} - /> - ))} - </Box> - - {/* 右侧控制面板骨架 (抽屉式) */} - <Box - sx={{ - position: "absolute", - top: 0, - right: 0, - width: { xs: "100%", sm: 360 }, - height: "100%", - bgcolor: "background.paper", - borderLeft: 1, - borderColor: "divider", - p: 3, - zIndex: 5, - display: { xs: "none", md: "flex" }, - flexDirection: "column", - boxShadow: -2, - }} - > - <Skeleton variant="text" width="60%" height={40} sx={{ mb: 3 }} /> - - {/* 面板内容区块 */} - <Box sx={{ flex: 1, overflow: "hidden" }}> - <Skeleton variant="rectangular" width="100%" height={100} sx={{ mb: 2, borderRadius: 1 }} /> - <Skeleton variant="text" width="40%" height={24} sx={{ mb: 1 }} /> - <Skeleton variant="rectangular" width="100%" height={180} sx={{ mb: 2, borderRadius: 1 }} /> - - <Box sx={{ mt: 2 }}> - {[1, 2, 3].map((i) => ( - <Box key={i} sx={{ display: "flex", gap: 2, mb: 2 }}> - <Skeleton variant="circular" width={36} height={36} /> - <Box sx={{ flex: 1 }}> - <Skeleton variant="text" width="80%" /> - <Skeleton variant="text" width="50%" /> - </Box> - </Box> - ))} - </Box> - </Box> - </Box> - - {/* 底部时间轴/控制条骨架 */} - <Box - sx={{ - position: "absolute", - bottom: 30, - left: "50%", - transform: "translateX(-50%)", - width: { xs: "90%", md: "60%" }, - height: 64, - bgcolor: "background.paper", - borderRadius: 4, - boxShadow: 3, - p: 2, - display: "flex", - alignItems: "center", - gap: 2, - zIndex: 5, - }} - > - <Skeleton variant="circular" width={32} height={32} /> - <Skeleton variant="rectangular" width="100%" height={8} sx={{ borderRadius: 4 }} /> - <Skeleton variant="text" width={40} /> - </Box> - - {/* 缩放控制骨架 (右下) */} - <Box - sx={{ - position: "absolute", - bottom: 110, - right: { xs: 20, md: 380 }, // Adjust if drawer is open - display: "flex", - flexDirection: "column", - gap: 1, - zIndex: 4, - }} - > - <Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} /> - <Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} /> - </Box> - </Box> - ); -} - -/** - * 简化版骨架屏 - 用于非地图页面 - */ -export function SimpleSkeleton() { - return ( - <Box - sx={{ - width: "100%", - height: "100%", - p: 3, - bgcolor: "background.default", - }} - > - <Skeleton width="40%" height={40} animation="wave" sx={{ mb: 3 }} /> - <Skeleton width="100%" height={60} animation="wave" sx={{ mb: 2 }} /> - <Skeleton width="100%" height={300} animation="wave" sx={{ mb: 2 }} /> - <Box sx={{ display: "flex", gap: 2, mb: 2 }}> - <Skeleton - variant="rectangular" - width="30%" - height={150} - animation="wave" - /> - <Skeleton - variant="rectangular" - width="30%" - height={150} - animation="wave" - /> - <Skeleton - variant="rectangular" - width="30%" - height={150} - animation="wave" - /> - </Box> - </Box> - ); -} diff --git a/src/components/loading/mapComponentSkeletonConfig.ts b/src/components/loading/mapComponentSkeletonConfig.ts new file mode 100644 index 0000000..9a1a041 --- /dev/null +++ b/src/components/loading/mapComponentSkeletonConfig.ts @@ -0,0 +1,120 @@ +export const MAP_SKELETON_VARIANTS = [ + "network-simulation", + "scada-data-cleaning", + "health-risk-analysis", + "monitoring-place-optimization", + "burst-detection", + "burst-location", + "burst-simulation", + "contaminant-simulation", + "dma-leak-detection", + "flushing-analysis", +] as const; + +export type MapSkeletonVariant = (typeof MAP_SKELETON_VARIANTS)[number]; + +export interface MapSkeletonConfig { + title: string; + panelTitle: string; + side: "left" | "right"; + panelWidth: number; + panelTop: number; + panelMaxHeight: number; + tabLabels: readonly string[]; +} + +export const MAP_SKELETON_CONFIGS: Record< + MapSkeletonVariant, + MapSkeletonConfig +> = { + "network-simulation": { + title: "管网模拟", + panelTitle: "SCADA 设备列表", + side: "left", + panelWidth: 360, + panelTop: 80, + panelMaxHeight: 860, + tabLabels: [], + }, + "scada-data-cleaning": { + title: "数据清洗", + panelTitle: "SCADA 设备列表", + side: "left", + panelWidth: 360, + panelTop: 80, + panelMaxHeight: 860, + tabLabels: [], + }, + "health-risk-analysis": { + title: "健康风险分析", + panelTitle: "管道健康风险统计", + side: "right", + panelWidth: 640, + panelTop: 16, + panelMaxHeight: 614, + tabLabels: [], + }, + "monitoring-place-optimization": { + title: "监测点优化", + panelTitle: "监测点优化", + side: "right", + panelWidth: 520, + panelTop: 16, + panelMaxHeight: 850, + tabLabels: ["优化要件", "结果编辑", "方案查询"], + }, + "burst-detection": { + title: "爆管侦测", + panelTitle: "爆管侦测", + side: "right", + panelWidth: 450, + panelTop: 16, + panelMaxHeight: 850, + tabLabels: ["侦测参数", "方案查询", "侦测结果"], + }, + "burst-location": { + title: "爆管定位", + panelTitle: "爆管定位", + side: "right", + panelWidth: 450, + panelTop: 16, + panelMaxHeight: 850, + tabLabels: ["定位参数", "方案查询", "定位结果"], + }, + "burst-simulation": { + title: "爆管分析", + panelTitle: "爆管分析", + side: "right", + panelWidth: 520, + panelTop: 16, + panelMaxHeight: 850, + tabLabels: ["分析要件", "方案查询", "分析报告", "关阀分析"], + }, + "contaminant-simulation": { + title: "水质模拟", + panelTitle: "水质模拟", + side: "right", + panelWidth: 520, + panelTop: 16, + panelMaxHeight: 850, + tabLabels: ["分析要件", "方案查询", "模拟结果"], + }, + "dma-leak-detection": { + title: "DMA 漏损识别", + panelTitle: "DMA 漏损识别", + side: "right", + panelWidth: 450, + panelTop: 16, + panelMaxHeight: 850, + tabLabels: ["识别参数", "方案查询", "识别结果"], + }, + "flushing-analysis": { + title: "管道冲洗分析", + panelTitle: "管道冲洗分析", + side: "right", + panelWidth: 450, + panelTop: 16, + panelMaxHeight: 850, + tabLabels: ["分析参数", "方案查询"], + }, +}; diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index b35f50c..25d500f 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -35,7 +35,7 @@ interface ToolbarProps { hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style'] queryType?: string; // 可选的查询类型参数 schemeType?: string; // 可选的方案类型参数 - HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板 + HistoryPanel?: React.ComponentType<any>; // 可选的自定义历史数据面板 enableCompare?: boolean; } diff --git a/src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx b/src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx index 552aad4..cfed455 100644 --- a/src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx +++ b/src/components/olmap/core/Controls/ToolbarHistoryPanel.tsx @@ -16,7 +16,7 @@ type ToolbarHistoryPanelProps = { endTime?: string; } | null; highlightFeatures: Feature[]; - HistoryPanel?: React.FC<any>; + HistoryPanel?: React.ComponentType<any>; schemeName?: string; queryType?: string; onClose: () => void; -- 2.54.0 From 8836549697cf9693873af0e273e76d8bc2a28130 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 14:21:20 +0800 Subject: [PATCH 249/281] fix(auth): unify scheme creator display --- src/app/RefineContext.tsx | 1 + src/app/api/auth/[...nextauth]/options.ts | 10 ++++++ .../olmap/BurstLocation/SchemeQuery.tsx | 4 ++- .../BurstSimulation/AnalysisReport.test.tsx | 2 +- .../olmap/BurstSimulation/AnalysisReport.tsx | 2 +- .../BurstPipeAnalysisPanel.test.tsx | 2 +- .../olmap/BurstSimulation/SchemeQuery.tsx | 6 ++-- src/components/olmap/BurstSimulation/types.ts | 2 +- .../ContaminantSimulation/SchemeQuery.tsx | 6 ++-- .../olmap/ContaminantSimulation/types.ts | 2 +- .../olmap/DMALeakDetection/SchemeQuery.tsx | 4 ++- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 9 ++++-- .../olmap/FlushingAnalysis/types.ts | 2 +- .../SchemeQuery.tsx | 9 ++++-- .../olmap/core/useSchemeCreatorName.test.ts | 31 +++++++++++++++++++ .../olmap/core/useSchemeCreatorName.ts | 29 +++++++++++++++++ src/types/next-auth.d.ts | 3 ++ 17 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 src/components/olmap/core/useSchemeCreatorName.test.ts create mode 100644 src/components/olmap/core/useSchemeCreatorName.ts diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 4826171..87773c8 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -146,6 +146,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => { const { user } = data; return { id: user.id, + username: user.username, name: user.name, avatar: user.image, }; diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 194af83..5af6bae 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -66,6 +66,7 @@ const authOptions: NextAuthOptions = { profile(profile) { return { id: profile.sub, + username: profile.preferred_username, name: profile.name ?? profile.preferred_username, email: profile.email, image: Avatar.src, @@ -79,6 +80,12 @@ const authOptions: NextAuthOptions = { if (profile?.sub) { token.sub = profile.sub; } + const preferredUsername = ( + profile as { preferred_username?: unknown } | undefined + )?.preferred_username; + if (typeof preferredUsername === "string") { + token.username = preferredUsername; + } if (account) { if (account.access_token) { @@ -104,6 +111,9 @@ const authOptions: NextAuthOptions = { if (session.user && token.sub) { session.user.id = token.sub; } + if (session.user && token.username) { + session.user.username = token.username; + } if (token.accessToken) { session.accessToken = token.accessToken; } diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index a001daa..5899981 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -42,6 +42,7 @@ import { BurstSchemeRecord, } from "./types"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; +import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; interface Props { onViewResult: (result: BurstLocationResult) => void; @@ -86,6 +87,7 @@ const SchemeQuery: React.FC<Props> = ({ const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {}; const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [loading, setLoading] = useState(false); + const creatorName = useSchemeCreatorName(); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; const sortedSchemes = useMemo( @@ -526,7 +528,7 @@ const SchemeQuery: React.FC<Props> = ({ 用户: </Typography> <Typography variant="caption" className="font-medium text-gray-900"> - {scheme.username || "-"} + {creatorName(scheme.username)} </Typography> </Box> </Box> diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx index b33ef70..1571911 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx @@ -21,7 +21,7 @@ const scheme: SchemeRecord = { id: 17, schemeName: "burst-report-demo", type: "burst_analysis", - user: "operator", + username: "operator", create_time: "2026-07-30T08:00:00+08:00", startTime: "2026-07-30T09:00:00+08:00", schemeDetail: { diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.tsx index 519eea2..330252e 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.tsx @@ -255,7 +255,7 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({ {[ ["管网", NETWORK_NAME], ["方案名称", scheme.schemeName], - ["方案创建人", scheme.user || "未记录"], + ["方案创建人", scheme.username || "未记录"], ["方案创建时间", formatDateTime(scheme.create_time)], ["模拟开始时间", formatDateTime(scheme.startTime)], ["模拟持续时间", formatDuration(duration)], diff --git a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx index 7e6b7a6..1089de5 100644 --- a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx +++ b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx @@ -18,7 +18,7 @@ jest.mock("./SchemeQuery", () => ({ id, schemeName, type: "burst_analysis", - user: "operator", + username: "operator", create_time: "2026-07-30T08:00:00+08:00", startTime: "2026-07-30T09:00:00+08:00", schemeDetail: { diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index 734ddef..e72af40 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -56,6 +56,7 @@ import { getPipeDiameterDisplay, type PipeDiameterMap, } from "./schemePipeDiameters"; +import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; interface SchemeQueryProps { schemes?: SchemeRecord[]; @@ -113,6 +114,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); + const creatorName = useSchemeCreatorName(); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素 const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState< Record<number, PipeDiameterMap> @@ -177,7 +179,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ id: item.scheme_id, schemeName: item.scheme_name, type: item.scheme_type, - user: item.username, + username: item.username, create_time: item.create_time, startTime: item.scheme_start_time, schemeDetail: item.scheme_detail, @@ -723,7 +725,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ variant="caption" className="font-medium text-gray-900" > - {scheme.user} + {creatorName(scheme.username)} </Typography> </Box> <Box className="flex items-center gap-2"> diff --git a/src/components/olmap/BurstSimulation/types.ts b/src/components/olmap/BurstSimulation/types.ts index 91abb26..8db31a0 100644 --- a/src/components/olmap/BurstSimulation/types.ts +++ b/src/components/olmap/BurstSimulation/types.ts @@ -11,7 +11,7 @@ export interface SchemeRecord { id: number; schemeName: string; type: string; - user: string; + username: string; create_time: string; startTime: string; // 详情信息 diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index c9607f4..5f4173c 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -40,6 +40,7 @@ import Feature from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; import Timeline from "@components/olmap/core/Controls/Timeline"; import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types"; +import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; interface SchemeQueryProps { schemes?: ContaminantSchemeRecord[]; @@ -101,6 +102,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ >([]); const [loading, setLoading] = useState<boolean>(false); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); + const creatorName = useSchemeCreatorName(); const { open } = useNotification(); const map = useMap(); @@ -242,7 +244,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ id: item.scheme_id, schemeName: item.scheme_name, type: item.scheme_type, - user: item.username, + username: item.username, create_time: item.create_time, startTime: item.scheme_start_time, schemeDetail: item.scheme_detail, @@ -598,7 +600,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ variant="caption" className="font-medium text-gray-900" > - {scheme.user} + {creatorName(scheme.username)} </Typography> </Box> <Box className="flex items-center gap-2"> diff --git a/src/components/olmap/ContaminantSimulation/types.ts b/src/components/olmap/ContaminantSimulation/types.ts index fd8ef45..8fc2841 100644 --- a/src/components/olmap/ContaminantSimulation/types.ts +++ b/src/components/olmap/ContaminantSimulation/types.ts @@ -9,7 +9,7 @@ export interface ContaminantSchemeRecord { id: number; schemeName: string; type: string; - user: string; + username: string; create_time: string; startTime: string; schemeDetail?: ContaminantSchemeDetail; diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index c92c110..c599550 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -26,6 +26,7 @@ import { NETWORK_NAME, config } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; +import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; interface Props { onViewResult: (result: LeakageResultDetail) => void; @@ -63,6 +64,7 @@ const SchemeQuery: React.FC<Props> = ({ const { queryAll, queryDate, expandedId } = queryState; const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]); const [loading, setLoading] = useState(false); + const creatorName = useSchemeCreatorName(); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; const setSchemes = onSchemesChange || setInternalSchemes; const sortedSchemes = useMemo( @@ -265,7 +267,7 @@ const SchemeQuery: React.FC<Props> = ({ 用户: </Typography> <Typography variant="caption" className="font-medium text-gray-900"> - {scheme.username || "-"} + {creatorName(scheme.username)} </Typography> </Box> <Box className="grid grid-cols-[78px_1fr] items-center gap-x-2"> diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index fdd0203..1515897 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -42,6 +42,7 @@ import { bbox, featureCollection } from "@turf/turf"; import Timeline from "@components/olmap/core/Controls/Timeline"; import { SchemeRecord, SchemaItem } from "./types"; import { FLOW_DISPLAY_UNIT } from "@utils/units"; +import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; interface SchemeQueryProps { schemes?: SchemeRecord[]; @@ -100,6 +101,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); + const creatorName = useSchemeCreatorName(); const { open } = useNotification(); const map = useMap(); @@ -290,7 +292,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ id: item.scheme_id, schemeName: item.scheme_name, type: item.scheme_type, - user: item.username, + username: item.username, create_time: item.create_time, startTime: item.scheme_start_time, schemeDetail: item.scheme_detail, @@ -479,7 +481,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ variant="caption" className="text-gray-500 block" > - 用户: {scheme.user} · 时间: {formatTime(scheme.create_time)} + 用户: {creatorName(scheme.username)} · 时间:{" "} + {formatTime(scheme.create_time)} </Typography> </Box> <Box className="flex gap-1 ml-2"> @@ -579,7 +582,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ 用户: </Typography> <Typography variant="caption" className="font-medium text-gray-900"> - {scheme.user} + {creatorName(scheme.username)} </Typography> </Box> diff --git a/src/components/olmap/FlushingAnalysis/types.ts b/src/components/olmap/FlushingAnalysis/types.ts index 776bcad..ec37ce6 100644 --- a/src/components/olmap/FlushingAnalysis/types.ts +++ b/src/components/olmap/FlushingAnalysis/types.ts @@ -9,7 +9,7 @@ export interface SchemeRecord { id: number; schemeName: string; type: string; - user: string; + username: string; create_time: string; startTime: string; // 详情信息 diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 58ba297..6e59fc5 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -37,6 +37,7 @@ import VectorSource from "ol/source/Vector"; import { Style, Icon, Circle, Fill, Stroke } from "ol/style"; import Feature, { FeatureLike } from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; +import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; interface SchemeRecord { id: number; @@ -96,6 +97,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ const { queryAll, queryDate, expandedId } = queryState; const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); + const creatorName = useSchemeCreatorName(); const { open } = useNotification(); const map = useMap(); @@ -399,8 +401,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ variant="caption" className="text-gray-500 block" > - 最小半径: {scheme.minDiameter} · 用户: {scheme.user} · - 日期: {formatShortDate(scheme.create_time)} + 最小半径: {scheme.minDiameter} · 用户:{" "} + {creatorName(scheme.user)} · 日期:{" "} + {formatShortDate(scheme.create_time)} </Typography> </Box> {/* 操作按钮 */} @@ -490,7 +493,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ variant="caption" className="font-medium text-gray-900" > - {scheme.user} + {creatorName(scheme.user)} </Typography> </Box> <Box className="flex items-center gap-2"> diff --git a/src/components/olmap/core/useSchemeCreatorName.test.ts b/src/components/olmap/core/useSchemeCreatorName.test.ts new file mode 100644 index 0000000..c303698 --- /dev/null +++ b/src/components/olmap/core/useSchemeCreatorName.test.ts @@ -0,0 +1,31 @@ +import { resolveSchemeCreatorName } from "./useSchemeCreatorName"; + +describe("resolveSchemeCreatorName", () => { + it("shows the current viewer's display name for their own scheme", () => { + expect( + resolveSchemeCreatorName("tjwater", { + username: "tjwater", + name: "Test Account", + }), + ).toBe("Test Account"); + }); + + it("keeps the stored username for another user's scheme", () => { + expect( + resolveSchemeCreatorName("operator", { + username: "tjwater", + name: "Test Account", + }), + ).toBe("operator"); + }); + + it("falls back safely when identity data is incomplete", () => { + expect( + resolveSchemeCreatorName("tjwater", { + username: "tjwater", + name: " ", + }), + ).toBe("tjwater"); + expect(resolveSchemeCreatorName(undefined, undefined)).toBe("-"); + }); +}); diff --git a/src/components/olmap/core/useSchemeCreatorName.ts b/src/components/olmap/core/useSchemeCreatorName.ts new file mode 100644 index 0000000..6dc1dcf --- /dev/null +++ b/src/components/olmap/core/useSchemeCreatorName.ts @@ -0,0 +1,29 @@ +import { useGetIdentity } from "@refinedev/core"; +import { useCallback } from "react"; + +export interface SchemeViewerIdentity { + name?: string | null; + username?: string | null; +} + +export const resolveSchemeCreatorName = ( + schemeUsername: string | null | undefined, + viewer: SchemeViewerIdentity | null | undefined, +) => { + const username = schemeUsername?.trim(); + if (!username) return "-"; + + const viewerUsername = viewer?.username?.trim(); + const viewerName = viewer?.name?.trim(); + return viewerUsername === username && viewerName ? viewerName : username; +}; + +export const useSchemeCreatorName = () => { + const { data: viewer } = useGetIdentity<SchemeViewerIdentity>(); + + return useCallback( + (schemeUsername: string | null | undefined) => + resolveSchemeCreatorName(schemeUsername, viewer), + [viewer], + ); +}; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index b67a3a9..e2a0580 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -7,6 +7,7 @@ declare module "next-auth" { error?: "RefreshAccessTokenError"; user?: { id?: string; + username?: string; name?: string | null; email?: string | null; image?: string | null; @@ -15,12 +16,14 @@ declare module "next-auth" { interface User { id?: string; + username?: string; } } declare module "next-auth/jwt" { interface JWT { sub?: string; + username?: string; accessToken?: string; refreshToken?: string; accessTokenExpires?: number; -- 2.54.0 From 04557b9363222be9890f4846e73915af1745fc12 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 14:34:43 +0800 Subject: [PATCH 250/281] =?UTF-8?q?feat(ui):=20=E7=BB=9F=E4=B8=80=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=A9=BA=E7=8A=B6=E6=80=81=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/BurstDetection/DetectionResults.tsx | 17 +-- .../olmap/BurstDetection/SchemeQuery.tsx | 23 ++-- .../olmap/BurstLocation/LocationResults.tsx | 17 +-- .../olmap/BurstLocation/SchemeQuery.tsx | 50 +++----- .../BurstSimulation/AnalysisReport.test.tsx | 18 +++ .../olmap/BurstSimulation/AnalysisReport.tsx | 15 +-- .../olmap/BurstSimulation/SchemeQuery.tsx | 53 +++------ .../ContaminantSimulation/SchemeQuery.tsx | 53 +++------ .../DMALeakDetection/RecognitionResults.tsx | 44 ++----- .../olmap/DMALeakDetection/SchemeQuery.tsx | 50 +++----- .../FlushingAnalysis/AnalysisParameters.tsx | 27 +++-- .../olmap/FlushingAnalysis/SchemeQuery.tsx | 53 +++------ .../HealthRiskStatistics.tsx | 24 +--- .../HealthRiskAnalysis/PredictDataPanel.tsx | 26 +--- .../SchemeQuery.tsx | 54 +++------ src/components/olmap/SCADA/SCADADataPanel.tsx | 49 +++----- .../olmap/SCADA/SCADADeviceList.tsx | 30 ++--- .../olmap/common/PanelEmptyState.test.tsx | 46 +++++++ .../olmap/common/PanelEmptyState.tsx | 112 ++++++++++++++++++ .../olmap/core/Controls/HistoryDataPanel.tsx | 49 +++----- .../olmap/core/Controls/PropertyPanel.tsx | 25 ++-- 21 files changed, 400 insertions(+), 435 deletions(-) create mode 100644 src/components/olmap/common/PanelEmptyState.test.tsx create mode 100644 src/components/olmap/common/PanelEmptyState.tsx diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx index 6c183c5..516b4d3 100644 --- a/src/components/olmap/BurstDetection/DetectionResults.tsx +++ b/src/components/olmap/BurstDetection/DetectionResults.tsx @@ -23,6 +23,7 @@ import { bbox, featureCollection } from "@turf/turf"; import { useMap } from "@components/olmap/core/MapComponent"; import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { BurstDetectionResult, BurstDetectionRow } from "./types"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; export interface BurstDetectionResultsState { selectedDay: number | null; @@ -73,17 +74,11 @@ const formatDateTime = (value?: string) => value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"; const EmptyState = () => ( - <Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> - <Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> - <ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> - </Box> - <Typography variant="h6" className="mb-1 font-bold text-gray-700"> - 等待侦测结果 - </Typography> - <Typography variant="body2" className="max-w-xs text-gray-500"> - 提交侦测后,这里会展示目标时刻状态、前 14 天参考分数和异常测点。 - </Typography> - </Box> + <PanelEmptyState + icon={<ShowChartIcon />} + title="尚未生成侦测结果" + description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。" + /> ); const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => { diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx index 4385e8c..7486bda 100644 --- a/src/components/olmap/BurstDetection/SchemeQuery.tsx +++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx @@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { NETWORK_NAME } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; import { BurstDetectionResult, BurstDetectionSchemeDetail, @@ -42,6 +43,7 @@ export interface BurstDetectionSchemeQueryState { queryAll: boolean; queryDate: Dayjs | null; expandedId: number | null; + hasQueried: boolean; } export const createBurstDetectionSchemeQueryState = @@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState = queryAll: true, queryDate: dayjs(), expandedId: null, + hasQueried: false, }); const SchemeQuery: React.FC<Props> = ({ @@ -64,7 +67,7 @@ const SchemeQuery: React.FC<Props> = ({ onStateChange, createBurstDetectionSchemeQueryState(), ); - const { queryAll, queryDate, expandedId } = queryState; + const { queryAll, queryDate, expandedId, hasQueried } = queryState; const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]); const [loading, setLoading] = useState(false); const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; @@ -138,6 +141,7 @@ const SchemeQuery: React.FC<Props> = ({ const response = await api.get("/api/v1/schemes", { params }); const nextSchemes = response.data as BurstDetectionSchemeRecord[]; setSchemes(nextSchemes); + setQueryField("hasQueried", true); open?.({ type: "success", message: "查询成功", @@ -203,7 +207,10 @@ const SchemeQuery: React.FC<Props> = ({ <Checkbox size="small" checked={queryAll} - onChange={(event) => setQueryField("queryAll", event.target.checked)} + onChange={(event) => { + setQueryField("queryAll", event.target.checked); + setQueryField("hasQueried", false); + }} /> } label={<Typography variant="body2">查询全部</Typography>} @@ -212,7 +219,10 @@ const SchemeQuery: React.FC<Props> = ({ <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <DatePicker value={queryDate} - onChange={(value) => setQueryField("queryDate", value)} + onChange={(value) => { + setQueryField("queryDate", value); + setQueryField("hasQueried", false); + }} disabled={queryAll} format="YYYY-MM-DD" slotProps={{ textField: { size: "small", sx: { width: 180 } } }} @@ -234,12 +244,7 @@ const SchemeQuery: React.FC<Props> = ({ <Box className="flex-1 overflow-auto"> {sortedSchemes.length === 0 ? ( - <Box className="flex h-full flex-col items-center justify-center text-center text-gray-400"> - <Typography variant="body2">暂无侦测方案</Typography> - <Typography variant="caption" className="mt-1"> - 运行一次展示版侦测后,可在这里回看历史结果。 - </Typography> - </Box> + <SchemeQueryEmptyState hasQueried={hasQueried} /> ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="px-2 text-gray-500"> diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index 6bf7a8a..809171e 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -31,6 +31,7 @@ import { Stroke, Style, Circle, Fill } from "ol/style"; import { bbox, featureCollection } from "@turf/turf"; import { BurstCandidate, BurstLocationResult } from "./types"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface Props { result: BurstLocationResult | null; @@ -128,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { }; const EmptyState = () => ( - <Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> - <Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> - <MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> - </Box> - <Typography variant="h6" className="mb-1 font-bold text-gray-700"> - 等待定位结果 - </Typography> - <Typography variant="body2" className="max-w-xs text-gray-500"> - 请先提交爆管定位分析,结果面板将展示定位摘要、时间窗、采样情况和候选管段。 - </Typography> - </Box> + <PanelEmptyState + icon={<MapIcon />} + title="尚未生成定位结果" + description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。" + /> ); const LocationResults: React.FC<Props> = ({ result }) => { diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index 5899981..23127d7 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -43,6 +43,7 @@ import { } from "./types"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; +import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; interface Props { onViewResult: (result: BurstLocationResult) => void; @@ -57,6 +58,7 @@ export interface BurstLocationSchemeQueryState { queryDate: Dayjs | null; expandedId: number | null; simulationBurstIdsByName: Record<string, string[]>; + hasQueried: boolean; } export const createBurstLocationSchemeQueryState = @@ -65,6 +67,7 @@ export const createBurstLocationSchemeQueryState = queryDate: dayjs(), expandedId: null, simulationBurstIdsByName: {}, + hasQueried: false, }); const SchemeQuery: React.FC<Props> = ({ @@ -83,7 +86,7 @@ const SchemeQuery: React.FC<Props> = ({ onStateChange, createBurstLocationSchemeQueryState(), ); - const { queryAll, queryDate, expandedId } = queryState; + const { queryAll, queryDate, expandedId, hasQueried } = queryState; const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {}; const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [loading, setLoading] = useState(false); @@ -256,6 +259,7 @@ const SchemeQuery: React.FC<Props> = ({ ), ), ); + setQueryField("hasQueried", true); open?.({ type: "success", message: "查询成功", @@ -320,7 +324,10 @@ const SchemeQuery: React.FC<Props> = ({ <Checkbox size="small" checked={queryAll} - onChange={(e) => setQueryField("queryAll", e.target.checked)} + onChange={(e) => { + setQueryField("queryAll", e.target.checked); + setQueryField("hasQueried", false); + }} /> } label={<Typography variant="body2">查询全部</Typography>} @@ -329,7 +336,10 @@ const SchemeQuery: React.FC<Props> = ({ <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <DatePicker value={queryDate} - onChange={(value) => setQueryField("queryDate", value)} + onChange={(value) => { + setQueryField("queryDate", value); + setQueryField("hasQueried", false); + }} disabled={queryAll} format="YYYY-MM-DD" slotProps={{ textField: { size: "small", sx: { width: 200 } } }} @@ -350,39 +360,7 @@ const SchemeQuery: React.FC<Props> = ({ </Box> <Box className="flex-1 overflow-auto"> {sortedSchemes.length === 0 ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <rect - x="10" - y="20" - width="60" - height="45" - rx="2" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="10" - y1="30" - x2="70" - y2="30" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">总共 0 条</Typography> - <Typography variant="body2" className="mt-1"> - No data - </Typography> - </Box> + <SchemeQueryEmptyState hasQueried={hasQueried} /> ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx index 1571911..4032017 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx @@ -62,6 +62,24 @@ describe("AnalysisReport", () => { document.body.classList.remove("burst-analysis-report-printing"); }); + it("guides the user to choose a scheme when no report is active", () => { + render( + <AnalysisReport + scheme={null} + valveResult={null} + disabledValves={[]} + generatedAt={null} + />, + ); + + expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument(); + expect( + screen.getByText( + "请在“方案查询”中打开一个方案,再查看分析报告。", + ), + ).toBeInTheDocument(); + }); + it("renders the selected scheme, pipe data, and matching valve result", async () => { render( <AnalysisReport diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.tsx index 330252e..2049b0f 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.tsx @@ -35,6 +35,7 @@ import { type PipeDiameterMap, } from "./schemePipeDiameters"; import { SchemeRecord, ValveIsolationResult } from "./types"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface AnalysisReportProps { scheme: SchemeRecord | null; @@ -516,15 +517,11 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({ if (!scheme) { return ( - <Box className="flex h-full flex-col items-center justify-center px-6 text-center"> - <DescriptionOutlined sx={{ mb: 2, fontSize: 52, color: "#94a3b8" }} /> - <Typography variant="h6" className="font-bold text-gray-700"> - 等待选择分析方案 - </Typography> - <Typography variant="body2" className="mt-2 text-gray-500"> - 请在“方案查询”中点击“查看分析报告”。 - </Typography> - </Box> + <PanelEmptyState + icon={<DescriptionOutlined />} + title="尚未选择分析方案" + description="请在“方案查询”中打开一个方案,再查看分析报告。" + /> ); } diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index e72af40..e930d07 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -57,6 +57,7 @@ import { type PipeDiameterMap, } from "./schemePipeDiameters"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; +import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; interface SchemeQueryProps { schemes?: SchemeRecord[]; @@ -76,6 +77,7 @@ export interface BurstSchemeQueryState { selectedDate: Date | undefined; timeRange: { start: Date; end: Date } | undefined; expandedId: number | null; + hasQueried: boolean; } export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({ @@ -85,6 +87,7 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({ selectedDate: undefined, timeRange: undefined, expandedId: null, + hasQueried: false, }); const SchemeQuery: React.FC<SchemeQueryProps> = ({ @@ -107,6 +110,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ selectedDate, timeRange, expandedId, + hasQueried, } = queryState; const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); @@ -185,6 +189,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemeDetail: item.scheme_detail, })); setSchemes(nextSchemes); + setQueryField("hasQueried", true); if (filteredResults.length === 0) { open?.({ @@ -462,7 +467,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryField("queryAll", e.target.checked)} + onChange={(e) => { + setQueryField("queryAll", e.target.checked); + setQueryField("hasQueried", false); + }} size="small" /> } @@ -475,9 +483,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ > <DatePicker value={queryDate} - onChange={(value) => - value && dayjs.isDayjs(value) && setQueryField("queryDate", value) - } + onChange={(value) => { + if (value && dayjs.isDayjs(value)) { + setQueryField("queryDate", value); + setQueryField("hasQueried", false); + } + }} format="YYYY-MM-DD" disabled={queryAll} slotProps={{ @@ -505,39 +516,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ {/* 结果列表 */} <Box className="flex-1 overflow-auto"> {filteredSchemes.length === 0 ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <rect - x="10" - y="20" - width="60" - height="45" - rx="2" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="10" - y1="30" - x2="70" - y2="30" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">总共 0 条</Typography> - <Typography variant="body2" className="mt-1"> - No data - </Typography> - </Box> + <SchemeQueryEmptyState hasQueried={hasQueried} /> ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index 5f4173c..760a2b7 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -41,6 +41,7 @@ import { bbox, featureCollection } from "@turf/turf"; import Timeline from "@components/olmap/core/Controls/Timeline"; import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; +import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; interface SchemeQueryProps { schemes?: ContaminantSchemeRecord[]; @@ -60,6 +61,7 @@ export interface ContaminantSchemeQueryState { selectedDate: Date | undefined; timeRange: { start: Date; end: Date } | undefined; expandedId: number | null; + hasQueried: boolean; } export const createContaminantSchemeQueryState = @@ -70,6 +72,7 @@ export const createContaminantSchemeQueryState = selectedDate: undefined, timeRange: undefined, expandedId: null, + hasQueried: false, }); const SchemeQuery: React.FC<SchemeQueryProps> = ({ @@ -92,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ selectedDate, timeRange, expandedId, + hasQueried, } = queryState; const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null); @@ -250,6 +254,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemeDetail: item.scheme_detail, })); setSchemes(nextSchemes); + setQueryField("hasQueried", true); if (filteredResults.length === 0) { open?.({ @@ -351,7 +356,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryField("queryAll", e.target.checked)} + onChange={(e) => { + setQueryField("queryAll", e.target.checked); + setQueryField("hasQueried", false); + }} size="small" /> } @@ -364,9 +372,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ > <DatePicker value={queryDate} - onChange={(value) => - value && dayjs.isDayjs(value) && setQueryField("queryDate", value) - } + onChange={(value) => { + if (value && dayjs.isDayjs(value)) { + setQueryField("queryDate", value); + setQueryField("hasQueried", false); + } + }} format="YYYY-MM-DD" disabled={queryAll} slotProps={{ @@ -393,39 +404,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ <Box className="flex-1 overflow-auto"> {filteredSchemes.length === 0 ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <rect - x="10" - y="20" - width="60" - height="45" - rx="2" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="10" - y1="30" - x2="70" - y2="30" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">总共 0 条</Typography> - <Typography variant="body2" className="mt-1"> - No data - </Typography> - </Box> + <SchemeQueryEmptyState hasQueried={hasQueried} /> ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> diff --git a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx index dce5e08..a5a1fb2 100644 --- a/src/components/olmap/DMALeakDetection/RecognitionResults.tsx +++ b/src/components/olmap/DMALeakDetection/RecognitionResults.tsx @@ -11,11 +11,15 @@ import { TableHead, TableRow, } from "@mui/material"; -import { FormatListBulleted } from "@mui/icons-material"; +import { + FormatListBulleted, + TroubleshootOutlined, +} from "@mui/icons-material"; import dayjs from "dayjs"; import { getAreaColor } from "./utils"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { LeakageResultDetail } from "./types"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface Props { result: LeakageResultDetail | null; @@ -31,39 +35,11 @@ const RecognitionResults: React.FC<Props> = ({ result }) => { if (!result || !sortedRows.length) { return ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <rect - x="10" - y="20" - width="60" - height="45" - rx="2" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="10" - y1="30" - x2="70" - y2="30" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">暂无识别结果</Typography> - <Typography variant="body2" className="mt-1"> - 请先加载方案或执行识别分析 - </Typography> - </Box> + <PanelEmptyState + icon={<TroubleshootOutlined />} + title="尚未生成识别结果" + description="请在“识别参数”中运行分析,或在“方案查询”中打开历史结果。" + /> ); } diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index c599550..3e15444 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -27,6 +27,7 @@ import { useControllableObjectState } from "@components/olmap/core/useControllab import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; +import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; interface Props { onViewResult: (result: LeakageResultDetail) => void; @@ -40,12 +41,14 @@ export interface DMALeakSchemeQueryState { queryAll: boolean; queryDate: Dayjs | null; expandedId: number | null; + hasQueried: boolean; } export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({ queryAll: true, queryDate: dayjs(), expandedId: null, + hasQueried: false, }); const SchemeQuery: React.FC<Props> = ({ @@ -61,7 +64,7 @@ const SchemeQuery: React.FC<Props> = ({ onStateChange, createDMALeakSchemeQueryState(), ); - const { queryAll, queryDate, expandedId } = queryState; + const { queryAll, queryDate, expandedId, hasQueried } = queryState; const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]); const [loading, setLoading] = useState(false); const creatorName = useSchemeCreatorName(); @@ -93,6 +96,7 @@ const SchemeQuery: React.FC<Props> = ({ }); const nextSchemes = response.data as LeakageSchemeRecord[]; setSchemes(nextSchemes); + setQueryField("hasQueried", true); if (nextSchemes.length === 0) { open?.({ type: "success", @@ -150,7 +154,10 @@ const SchemeQuery: React.FC<Props> = ({ <Checkbox size="small" checked={queryAll} - onChange={(e) => setQueryField("queryAll", e.target.checked)} + onChange={(e) => { + setQueryField("queryAll", e.target.checked); + setQueryField("hasQueried", false); + }} /> } label={<Typography variant="body2">查询全部</Typography>} @@ -159,7 +166,10 @@ const SchemeQuery: React.FC<Props> = ({ <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <DatePicker value={queryDate} - onChange={(value) => setQueryField("queryDate", value)} + onChange={(value) => { + setQueryField("queryDate", value); + setQueryField("hasQueried", false); + }} disabled={queryAll} format="YYYY-MM-DD" slotProps={{ textField: { size: "small", sx: { width: 200 } } }} @@ -180,39 +190,7 @@ const SchemeQuery: React.FC<Props> = ({ </Box> <Box className="flex-1 overflow-auto"> {sortedSchemes.length === 0 ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <rect - x="10" - y="20" - width="60" - height="45" - rx="2" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="10" - y1="30" - x2="70" - y2="30" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">总共 0 条</Typography> - <Typography variant="body2" className="mt-1"> - No data - </Typography> - </Box> + <SchemeQueryEmptyState hasQueried={hasQueried} /> ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 8fdad66..77ec889 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -11,7 +11,11 @@ import { Alert, Divider, } from "@mui/material"; -import { Close as CloseIcon } from "@mui/icons-material"; +import { + AdjustOutlined, + Close as CloseIcon, + WaterDropOutlined, +} from "@mui/icons-material"; import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; @@ -31,6 +35,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; export interface ValveItem { id: string; @@ -383,9 +388,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ </Box> ))} {valves.length === 0 && ( - <Typography variant="caption" className="text-gray-400 text-center py-20"> - 暂无选中阀门 - </Typography> + <PanelEmptyState + variant="compact" + icon={<AdjustOutlined />} + title="尚未选择阀门" + description="点击“选择阀门”,然后在地图上添加。" + /> )} </Stack> </Box> @@ -412,7 +420,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ 💡 点击地图上的节点作为排水点 </Box> )} - <Stack spacing={1} className="h-12 overflow-auto"> + <Stack spacing={1} className="min-h-16 overflow-auto"> {drainageNode && ( <Box className="flex items-center gap-2 p-2 bg-gray-50 rounded"> <Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography> @@ -428,9 +436,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({ </Box> )} {!drainageNode && ( - <Typography variant="caption" className="text-gray-400 text-center py-2"> - 暂无选中排水节点 - </Typography> + <PanelEmptyState + variant="compact" + icon={<WaterDropOutlined />} + title="尚未选择排水节点" + description="点击“选择节点”,然后在地图上指定排水点。" + /> )} </Stack> </Box> diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index 1515897..e7fe0a8 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -43,6 +43,7 @@ import Timeline from "@components/olmap/core/Controls/Timeline"; import { SchemeRecord, SchemaItem } from "./types"; import { FLOW_DISPLAY_UNIT } from "@utils/units"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; +import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; interface SchemeQueryProps { schemes?: SchemeRecord[]; @@ -61,6 +62,7 @@ export interface FlushingSchemeQueryState { showTimeline: boolean; selectedDate: Date | undefined; timeRange: { start: Date; end: Date } | undefined; + hasQueried: boolean; } export const createFlushingSchemeQueryState = @@ -71,6 +73,7 @@ export const createFlushingSchemeQueryState = showTimeline: false, selectedDate: undefined, timeRange: undefined, + hasQueried: false, }); const SchemeQuery: React.FC<SchemeQueryProps> = ({ @@ -92,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ showTimeline, selectedDate, timeRange, + hasQueried, } = queryState; const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); @@ -298,6 +302,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ schemeDetail: item.scheme_detail, })); setSchemes(nextSchemes); + setQueryField("hasQueried", true); if (filteredResults.length === 0) { open?.({ @@ -371,7 +376,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryField("queryAll", e.target.checked)} + onChange={(e) => { + setQueryField("queryAll", e.target.checked); + setQueryField("hasQueried", false); + }} size="small" /> } @@ -384,9 +392,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ > <DatePicker value={queryDate} - onChange={(value) => - value && dayjs.isDayjs(value) && setQueryField("queryDate", value) - } + onChange={(value) => { + if (value && dayjs.isDayjs(value)) { + setQueryField("queryDate", value); + setQueryField("hasQueried", false); + } + }} format="YYYY-MM-DD" disabled={queryAll} slotProps={{ @@ -414,39 +425,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ {/* Results List */} <Box className="flex-1 overflow-auto"> {sortedSchemes.length === 0 ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <rect - x="10" - y="20" - width="60" - height="45" - rx="2" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="10" - y1="30" - x2="70" - y2="30" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">总共 0 条</Typography> - <Typography variant="body2" className="mt-1"> - No data - </Typography> - </Box> + <SchemeQueryEmptyState hasQueried={hasQueried} /> ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> diff --git a/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx b/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx index 07da4f0..7c0e7e0 100644 --- a/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx +++ b/src/components/olmap/HealthRiskAnalysis/HealthRiskStatistics.tsx @@ -23,6 +23,7 @@ import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types"; import { useHealthRisk } from "./HealthRiskContext"; import { useProject } from "@/contexts/ProjectContext"; import HealthRiskReport from "./HealthRiskReport"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; const SIMPLE_LABELS = [ "0.0 - 0.1", @@ -229,24 +230,11 @@ const HealthRiskStatistics: React.FC = () => { }; const renderEmpty = () => ( - <Box - sx={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - height: "100%", - color: "text.secondary", - }} - > - <BarChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} /> - <Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}> - 暂无预测数据 - </Typography> - <Typography variant="body2" color="text.secondary"> - 请先进行健康风险分析预测,以生成风险分布数据 - </Typography> - </Box> + <PanelEmptyState + icon={<BarChart />} + title="暂无风险预测结果" + description="请先运行健康风险预测,完成后将在这里展示风险分布统计。" + /> ); return ( diff --git a/src/components/olmap/HealthRiskAnalysis/PredictDataPanel.tsx b/src/components/olmap/HealthRiskAnalysis/PredictDataPanel.tsx index 8ab3f1a..7a6f653 100644 --- a/src/components/olmap/HealthRiskAnalysis/PredictDataPanel.tsx +++ b/src/components/olmap/HealthRiskAnalysis/PredictDataPanel.tsx @@ -8,6 +8,7 @@ import { ShowChart } from "@mui/icons-material"; import ReactECharts from "echarts-for-react"; import "dayjs/locale/zh-cn"; import { useHealthRisk } from "./HealthRiskContext"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; export interface PredictDataPanelProps { /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ @@ -58,26 +59,11 @@ const PredictDataPanel: React.FC<PredictDataPanelProps> = ({ }, [filteredResults]); const renderEmpty = () => ( - <Box - sx={{ - flex: 1, - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - py: 8, - color: "text.secondary", - height: "100%", - }} - > - <ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} /> - <Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}> - 暂无预测数据 - </Typography> - <Typography variant="body2" color="text.secondary"> - 请在地图上选择已分析的管道 - </Typography> - </Box> + <PanelEmptyState + icon={<ShowChart />} + title="暂无可展示的管道预测" + description="请先在地图上选择已完成健康风险分析的管道。" + /> ); const renderChart = () => { diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 6e59fc5..0036bd6 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -38,6 +38,7 @@ import { Style, Icon, Circle, Fill, Stroke } from "ol/style"; import Feature, { FeatureLike } from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; +import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; interface SchemeRecord { id: number; @@ -72,6 +73,7 @@ export interface MonitoringSchemeQueryState { queryAll: boolean; queryDate: Dayjs | null; expandedId: number | null; + hasQueried: boolean; } export const createMonitoringSchemeQueryState = @@ -79,6 +81,7 @@ export const createMonitoringSchemeQueryState = queryAll: true, queryDate: dayjs(new Date()), expandedId: null, + hasQueried: false, }); const SchemeQuery: React.FC<SchemeQueryProps> = ({ @@ -94,7 +97,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ onStateChange, createMonitoringSchemeQueryState(), ); - const { queryAll, queryDate, expandedId } = queryState; + const { queryAll, queryDate, expandedId, hasQueried } = queryState; const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [loading, setLoading] = useState<boolean>(false); const creatorName = useSchemeCreatorName(); @@ -207,6 +210,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ sensorLocation: item.sensor_location, })); setSchemes(nextSchemes); + setQueryField("hasQueried", true); if (filteredResults.length === 0) { open?.({ @@ -290,7 +294,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ control={ <Checkbox checked={queryAll} - onChange={(e) => setQueryField("queryAll", e.target.checked)} + onChange={(e) => { + setQueryField("queryAll", e.target.checked); + setQueryField("hasQueried", false); + }} size="small" /> } @@ -303,9 +310,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ > <DatePicker value={queryDate} - onChange={(value) => - value && dayjs.isDayjs(value) && setQueryField("queryDate", value) - } + onChange={(value) => { + if (value && dayjs.isDayjs(value)) { + setQueryField("queryDate", value); + setQueryField("hasQueried", false); + } + }} format="YYYY-MM-DD" disabled={queryAll} slotProps={{ @@ -333,39 +343,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({ {/* 结果列表 */} <Box className="flex-1 overflow-auto"> {sortedSchemes.length === 0 ? ( - <Box className="flex flex-col items-center justify-center h-full text-gray-400"> - <Box className="mb-4"> - <svg - width="80" - height="80" - viewBox="0 0 80 80" - fill="none" - className="opacity-40" - > - <rect - x="10" - y="20" - width="60" - height="45" - rx="2" - stroke="currentColor" - strokeWidth="2" - /> - <line - x1="10" - y1="30" - x2="70" - y2="30" - stroke="currentColor" - strokeWidth="2" - /> - </svg> - </Box> - <Typography variant="body2">总共 0 条</Typography> - <Typography variant="body2" className="mt-1"> - No data - </Typography> - </Box> + <SchemeQueryEmptyState hasQueried={hasQueried} /> ) : ( <Box className="space-y-2 p-2"> <Typography variant="caption" className="text-gray-500 px-2"> diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index adbeb05..e892175 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -40,6 +40,7 @@ import { useGetIdentity } from "@refinedev/core"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { apiFetch } from "@/lib/apiFetch"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; dayjs.extend(utc); dayjs.extend(timezone); @@ -314,11 +315,11 @@ const emptyStateMessages: Record< > = { chart: { title: "暂无时序数据", - subtitle: "请切换时间段来获取曲线", + subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", }, table: { title: "暂无表格数据", - subtitle: "请切换时间段来获取记录", + subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", }, }; @@ -721,28 +722,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ ); const renderEmpty = () => { + if (!hasDevices) { + return ( + <PanelEmptyState + icon={<ShowChart />} + title="尚未选择设备" + description="请先在设备列表中选择需要查看的 SCADA 设备。" + /> + ); + } + const message = emptyStateMessages[activeTab]; return ( - <Box - sx={{ - flex: 1, - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - py: 8, - color: "text.secondary", - height: "100%", - }} - > - <ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} /> - <Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}> - {message.title} - </Typography> - <Typography variant="body2" color="text.secondary"> - {message.subtitle} - </Typography> - </Box> + <PanelEmptyState + icon={activeTab === "chart" ? <ShowChart /> : <TableChart />} + title={message.title} + description={message.subtitle} + /> ); }; @@ -1254,15 +1250,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ </Stack> </LocalizationProvider> - {!hasDevices && ( - <Typography - variant="caption" - color="warning.main" - sx={{ mt: 1, display: "block" }} - > - 未选择任何设备,无法获取数据。 - </Typography> - )} {error && ( <Typography variant="caption" diff --git a/src/components/olmap/SCADA/SCADADeviceList.tsx b/src/components/olmap/SCADA/SCADADeviceList.tsx index 84a93a8..9870996 100644 --- a/src/components/olmap/SCADA/SCADADeviceList.tsx +++ b/src/components/olmap/SCADA/SCADADeviceList.tsx @@ -68,6 +68,7 @@ import dayjs, { Dayjs } from "dayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; const STATUS_OPTIONS: { value: "online" | "offline" | "warning" | "error"; @@ -1067,22 +1068,23 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({ <CircularProgress /> </Box> ) : filteredDevices.length === 0 ? ( - <Box - sx={{ - p: 4, - textAlign: "center", - color: "text.secondary", - }} - > - <DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} /> - <Typography variant="body2"> - {searchQuery || + <PanelEmptyState + icon={<DeviceHub />} + title={ + searchQuery || selectedType !== "all" || selectedStatus !== "all" - ? "未找到匹配的设备" - : "暂无 SCADA 设备"} - </Typography> - </Box> + ? "未找到匹配设备" + : "暂无 SCADA 设备" + } + description={ + searchQuery || + selectedType !== "all" || + selectedStatus !== "all" + ? "请清除关键词,或调整设备类型和状态筛选条件。" + : "请确认设备已接入当前管网后刷新列表。" + } + /> ) : ( <FixedSizeList height={listHeight} diff --git a/src/components/olmap/common/PanelEmptyState.test.tsx b/src/components/olmap/common/PanelEmptyState.test.tsx new file mode 100644 index 0000000..5471f9d --- /dev/null +++ b/src/components/olmap/common/PanelEmptyState.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { DescriptionOutlined } from "@mui/icons-material"; +import PanelEmptyState, { SchemeQueryEmptyState } from "./PanelEmptyState"; + +describe("PanelEmptyState", () => { + it("renders an accessible panel instruction", () => { + render( + <PanelEmptyState + icon={<DescriptionOutlined />} + title="尚未选择分析方案" + description="请在方案查询中打开一个方案。" + />, + ); + + expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument(); + expect(screen.getByText("请在方案查询中打开一个方案。")).toBeInTheDocument(); + }); + + it("distinguishes initial query guidance from an empty result", () => { + const { rerender } = render( + <SchemeQueryEmptyState hasQueried={false} />, + ); + + expect(screen.getByText("尚未查询历史方案")).toBeInTheDocument(); + + rerender(<SchemeQueryEmptyState hasQueried />); + + expect(screen.getByText("未找到符合条件的方案")).toBeInTheDocument(); + expect( + screen.getByText("请调整查询条件后重新查询,或先创建一个新方案。"), + ).toBeInTheDocument(); + }); + + it("supports compact selection guidance", () => { + render( + <PanelEmptyState + variant="compact" + title="尚未选择阀门" + description="点击“选择阀门”,然后在地图上添加。" + />, + ); + + expect(screen.getByText("尚未选择阀门")).toBeInTheDocument(); + }); +}); diff --git a/src/components/olmap/common/PanelEmptyState.tsx b/src/components/olmap/common/PanelEmptyState.tsx new file mode 100644 index 0000000..57d95bb --- /dev/null +++ b/src/components/olmap/common/PanelEmptyState.tsx @@ -0,0 +1,112 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Box, Typography } from "@mui/material"; +import { SearchOffOutlined } from "@mui/icons-material"; +import { alpha } from "@mui/material/styles"; + +export interface PanelEmptyStateProps { + icon?: ReactNode; + title: string; + description?: string; + variant?: "panel" | "compact"; +} + +const PanelEmptyState = ({ + icon, + title, + description, + variant = "panel", +}: PanelEmptyStateProps) => { + const compact = variant === "compact"; + + return ( + <Box + role="status" + aria-live="polite" + lang="zh-CN" + sx={(theme) => ({ + width: "100%", + height: compact ? "auto" : "100%", + minHeight: compact ? 48 : 220, + display: "flex", + flexDirection: compact ? "row" : { xs: "column", sm: "row" }, + alignItems: "center", + justifyContent: "center", + gap: compact ? 1.5 : { xs: 1.75, sm: 2.5 }, + px: compact ? 1.5 : { xs: 2, sm: 4 }, + py: compact ? 1.25 : { xs: 4, sm: 5 }, + color: "text.secondary", + textAlign: compact ? "left" : { xs: "center", sm: "left" }, + boxSizing: "border-box", + "& .MuiTypography-root": { + textWrap: "pretty", + }, + "& .MuiSvgIcon-root": { + fontSize: compact ? 22 : 32, + }, + "& > [aria-hidden='true']": { + flex: "0 0 auto", + width: compact ? 36 : 56, + height: compact ? 36 : 56, + borderRadius: compact ? 2 : 3, + display: "grid", + placeItems: "center", + color: "primary.main", + backgroundColor: alpha(theme.palette.primary.main, 0.08), + boxShadow: `inset 0 0 0 1px ${alpha( + theme.palette.primary.main, + 0.12, + )}`, + }, + })} + > + {icon ? <Box aria-hidden="true">{icon}</Box> : null} + <Box sx={{ minWidth: 0, maxWidth: compact ? "none" : 380 }}> + <Typography + variant={compact ? "body2" : "subtitle1"} + sx={{ + color: "text.primary", + fontWeight: 600, + lineHeight: compact ? 1.5 : 1.55, + }} + > + {title} + </Typography> + {description ? ( + <Typography + variant={compact ? "caption" : "body2"} + sx={{ + display: "block", + mt: compact ? 0.25 : 0.75, + color: "text.secondary", + lineHeight: compact ? 1.6 : 1.7, + }} + > + {description} + </Typography> + ) : null} + </Box> + </Box> + ); +}; + +export interface SchemeQueryEmptyStateProps { + hasQueried: boolean; +} + +export const SchemeQueryEmptyState = ({ + hasQueried, +}: SchemeQueryEmptyStateProps) => ( + <PanelEmptyState + icon={<SearchOffOutlined />} + title={hasQueried ? "未找到符合条件的方案" : "尚未查询历史方案"} + description={ + hasQueried + ? "请调整查询条件后重新查询,或先创建一个新方案。" + : "设置查询条件后点击“查询”,这里将显示可查看的历史方案。" + } + /> +); + +export default PanelEmptyState; diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 4455589..4849cd7 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -36,6 +36,7 @@ import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import config from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; dayjs.extend(utc); dayjs.extend(timezone); @@ -413,11 +414,11 @@ const emptyStateMessages: Record< > = { chart: { title: "暂无时序数据", - subtitle: "请切换时间段来获取曲线", + subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", }, table: { title: "暂无表格数据", - subtitle: "请切换时间段来获取记录", + subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", }, }; @@ -613,28 +614,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ ); const renderEmpty = () => { + if (!hasDevices) { + return ( + <PanelEmptyState + icon={<ShowChart />} + title="尚未选择地图要素" + description="请在地图上选择一个要素以查看历史数据。" + /> + ); + } + const message = emptyStateMessages[activeTab]; return ( - <Box - sx={{ - flex: 1, - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - py: 8, - color: "text.secondary", - height: "100%", - }} - > - <ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} /> - <Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}> - {message.title} - </Typography> - <Typography variant="body2" color="text.secondary"> - {message.subtitle} - </Typography> - </Box> + <PanelEmptyState + icon={activeTab === "chart" ? <ShowChart /> : <TableChart />} + title={message.title} + description={message.subtitle} + /> ); }; @@ -1048,15 +1044,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({ </Stack> </LocalizationProvider> - {!hasDevices && ( - <Typography - variant="caption" - color="warning.main" - sx={{ mt: 1, display: "block" }} - > - 请选择一个要素以查看其历史数据。 - </Typography> - )} {error && ( <Typography variant="caption" diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index ab8b923..fba4999 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState } from "react"; import Draggable from "react-draggable"; -import { Close } from "@mui/icons-material"; +import { Close, InfoOutlined } from "@mui/icons-material"; import { Button, CircularProgress, @@ -14,6 +14,7 @@ import { Tooltip, } from "@mui/material"; import type { SelectChangeEvent } from "@mui/material/Select"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface BaseProperty { label: string; @@ -162,23 +163,11 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({ {/* 内容区域 */} <div className="flex-1 overflow-y-auto px-4 py-3"> {!id ? ( - <div className="flex flex-col items-center justify-center py-12 text-gray-400"> - <svg - className="w-16 h-16 mb-3" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={1.5} - d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" - /> - </svg> - <p className="text-sm">暂无属性信息</p> - <p className="text-xs mt-1">请选择一个要素以查看其属性</p> - </div> + <PanelEmptyState + icon={<InfoOutlined />} + title="尚未选择地图要素" + description="请在地图上选择管道、节点或设备以查看属性。" + /> ) : ( <div className="space-y-2"> {/* ID 属性 */} -- 2.54.0 From d643f09fcff0a5fc2e18b3020494560a58aba830 Mon Sep 17 00:00:00 2001 From: Huarch <huarch97@outlook.com> Date: Thu, 30 Jul 2026 16:16:51 +0800 Subject: [PATCH 251/281] feat(sensor-placement): add scheme engineering editor --- .../MonitoringPlaceOptimizationPanel.tsx | 117 ++- .../OptimizationParameters.tsx | 106 +-- .../SchemeDrawingDialog.tsx | 665 +++++++++++++++ .../SchemeEditor.tsx | 804 ++++++++++++++++++ .../SchemeQuery.tsx | 51 +- .../engineeringDrawing.test.ts | 335 ++++++++ .../engineeringDrawing.ts | 382 +++++++++ .../MonitoringPlaceOptimization/schemeApi.ts | 73 ++ .../schemeEditor.test.ts | 84 ++ .../schemeEditor.ts | 158 ++++ .../MonitoringPlaceOptimization/types.ts | 45 + .../olmap/core/Controls/BaseLayers.test.ts | 26 +- .../olmap/core/Controls/BaseLayers.tsx | 37 +- 13 files changed, 2724 insertions(+), 159 deletions(-) create mode 100644 src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx create mode 100644 src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx create mode 100644 src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts create mode 100644 src/components/olmap/MonitoringPlaceOptimization/types.ts diff --git a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx index a7e9a95..772a720 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx @@ -9,6 +9,7 @@ import { Typography, IconButton, Tooltip, + CircularProgress, } from "@mui/material"; import { ChevronRight, @@ -16,6 +17,7 @@ import { Sensors as SensorsIcon, Analytics as AnalyticsIcon, Search as SearchIcon, + TableView as TableIcon, } from "@mui/icons-material"; import OptimizationParameters, { createOptimizationParametersState, @@ -25,16 +27,12 @@ import SchemeQuery, { createMonitoringSchemeQueryState, type MonitoringSchemeQueryState, } from "./SchemeQuery"; - -interface SchemeRecord { - id: number; - schemeName: string; - sensorNumber: number; - minDiameter: number; - user: string; - create_time: string; - sensorLocation?: string[]; -} +import SchemeEditor from "./SchemeEditor"; +import { getSensorPlacementScheme } from "./schemeApi"; +import type { SchemeRecord, SensorPlacementScheme } from "./types"; +import { NETWORK_NAME } from "@/config/config"; +import { useNotification } from "@refinedev/core"; +import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface TabPanelProps { children?: React.ReactNode; @@ -49,9 +47,7 @@ const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => { hidden={value !== index} className="flex-1 overflow-hidden flex flex-col" > - {value === index && ( - <Box className="flex-1 overflow-auto p-4">{children}</Box> - )} + <Box className="flex-1 overflow-auto p-4">{children}</Box> </div> ); }; @@ -66,6 +62,10 @@ const MonitoringPlaceOptimizationPanel: React.FC< > = ({ open: controlledOpen, onToggle }) => { const [internalOpen, setInternalOpen] = useState(true); const [currentTab, setCurrentTab] = useState(0); + const [activeScheme, setActiveScheme] = + useState<SensorPlacementScheme | null>(null); + const [loadingScheme, setLoadingScheme] = useState(false); + const { open: notify } = useNotification(); // 持久化方案查询结果 const [schemes, setSchemes] = useState<SchemeRecord[]>([]); @@ -89,7 +89,42 @@ const MonitoringPlaceOptimizationPanel: React.FC< setCurrentTab(newValue); }; - const drawerWidth = 520; + const drawerWidth = currentTab === 1 ? 820 : 520; + + const handleOpenScheme = async (schemeId: number) => { + setLoadingScheme(true); + setCurrentTab(1); + try { + setActiveScheme(await getSensorPlacementScheme(NETWORK_NAME, schemeId)); + } catch (error) { + const detail = + (error as { response?: { data?: { detail?: string } } }).response?.data + ?.detail || "无法读取方案详情"; + notify?.({ + type: "error", + message: "方案加载失败", + description: detail, + }); + setCurrentTab(2); + } finally { + setLoadingScheme(false); + } + }; + + const handleSchemeSaved = (scheme: SensorPlacementScheme) => { + setActiveScheme(scheme); + setSchemes((current) => + current.map((record) => + record.id === scheme.id + ? { + ...record, + sensorNumber: scheme.sensor_number, + sensorLocation: scheme.sensor_location, + } + : record, + ), + ); + }; return ( <> @@ -132,6 +167,7 @@ const MonitoringPlaceOptimizationPanel: React.FC< right: 16, height: "calc(100vh - 32px)", maxHeight: "850px", + maxWidth: "calc(100vw - 32px)", borderRadius: "12px", boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", @@ -193,6 +229,11 @@ const MonitoringPlaceOptimizationPanel: React.FC< iconPosition="start" label="优化要件" /> + <Tab + icon={<TableIcon fontSize="small" />} + iconPosition="start" + label="结果编辑" + /> <Tab icon={<SearchIcon fontSize="small" />} iconPosition="start" @@ -206,19 +247,59 @@ const MonitoringPlaceOptimizationPanel: React.FC< <OptimizationParameters state={optimizationState} onStateChange={setOptimizationState} + onSchemeCreated={(scheme) => { + setActiveScheme(scheme); + setCurrentTab(1); + setSchemes((current) => [ + ...current, + { + id: scheme.id, + schemeName: scheme.scheme_name, + sensorNumber: scheme.sensor_number, + minDiameter: scheme.min_diameter, + username: scheme.username, + create_time: scheme.create_time, + sensorLocation: scheme.sensor_location, + }, + ]); + }} /> </TabPanel> <TabPanel value={currentTab} index={1}> + {loadingScheme ? ( + <Box + sx={{ + minHeight: 320, + display: "grid", + placeItems: "center", + }} + > + <CircularProgress size={30} /> + </Box> + ) : activeScheme ? ( + <SchemeEditor + scheme={activeScheme} + network={NETWORK_NAME} + active={isOpen && currentTab === 1} + onSaved={handleSchemeSaved} + /> + ) : ( + <PanelEmptyState + icon={<TableIcon />} + title="暂无可编辑结果" + description="请先在“优化要件”中创建方案,或在“方案查询”中打开已有方案。" + /> + )} + </TabPanel> + + <TabPanel value={currentTab} index={2}> <SchemeQuery schemes={schemes} onSchemesChange={setSchemes} state={queryState} onStateChange={setQueryState} - onLocate={(id) => { - console.log("定位方案:", id); - // TODO: 在地图上定位 - }} + onEdit={handleOpenScheme} /> </TabPanel> </Box> diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index 3edeea9..adfd3fc 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -7,22 +7,15 @@ import { Button, Typography, MenuItem, - Stack, } from "@mui/material"; import { PlayArrow as PlayArrowIcon } from "@mui/icons-material"; import { useNotification } from "@refinedev/core"; -import { useGetIdentity } from "@refinedev/core"; -import { api } from "@/lib/api"; -import { config, NETWORK_NAME } from "@/config/config"; +import { NETWORK_NAME } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; - -type IUser = { - id: string; - name?: string; -}; +import { optimizeSensorPlacement } from "./schemeApi"; +import type { SensorPlacementScheme } from "./types"; export interface OptimizationParametersState { - sensorType: string; method: string; sensorCount: number; minDiameter: number; @@ -31,7 +24,6 @@ export interface OptimizationParametersState { export const createOptimizationParametersState = (): OptimizationParametersState => ({ - sensorType: "pressure", method: "kmeans", sensorCount: 5, minDiameter: 5, @@ -41,45 +33,32 @@ export const createOptimizationParametersState = interface OptimizationParametersProps { state?: OptimizationParametersState; onStateChange?: (state: OptimizationParametersState) => void; + onSchemeCreated?: (scheme: SensorPlacementScheme) => void; } const OptimizationParameters: React.FC<OptimizationParametersProps> = ({ state, onStateChange, + onSchemeCreated, }) => { const { open } = useNotification(); - const { data: user } = useGetIdentity<IUser>(); const [parametersState, , setFormField] = useControllableObjectState( state, onStateChange, createOptimizationParametersState(), ); - const { sensorType, method, sensorCount, minDiameter, schemeName } = + const { method, sensorCount, minDiameter, schemeName } = parametersState; - const [network] = useState<string>(NETWORK_NAME); + const network = NETWORK_NAME; const [analyzing, setAnalyzing] = useState<boolean>(false); - - // 传感器类型选项 - const sensorTypeOptions = [ - { value: "pressure", label: "压力" }, - { value: "flow", label: "流量" }, - ]; - // 方法选项 const methodOptions = [ { value: "kmeans", label: "聚类分析" }, { value: "sensitivity", label: "灵敏度分析" }, ]; - // 获取传感器类型的中文标签 - const getSensorTypeLabel = (value: string) => { - return ( - sensorTypeOptions.find((option) => option.value === value)?.label || value - ); - }; - // 创建方案 const handleCreateScheme = async () => { // 验证输入 @@ -109,48 +88,22 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({ setAnalyzing(true); - if (!user || !user.id) { - open?.({ - type: "error", - message: "用户信息无效", - }); - return; - } - try { - // 发送优化请求 - const response = await api.post( - `${config.BACKEND_URL}/api/v1/sensor-placement-schemes`, - null, - { - params: { - network: network, - scheme_name: schemeName, - sensor_type: sensorType, - method: method, - sensor_count: sensorCount, - min_diameter: minDiameter, - user_id: user.id, - user_name: user.name, - }, - } - ); - - console.log("响应数据:", response.data); // 添加日志以便调试 - - // 兼容后端返回字符串 "success" 或对象 { success: true } - if (response.data.success === true || response.data === "success") { - open?.({ - type: "success", - message: "方案创建成功", - description: `方案 "${schemeName}" 已完成优化分析`, - }); - - // 重置方案名称 - setFormField("schemeName", "Fangan" + new Date().getTime()); - } else { - throw new Error(response.data?.message || "创建失败"); - } + const created = await optimizeSensorPlacement({ + network, + scheme_name: schemeName, + sensor_type: "pressure", + method: method as "sensitivity" | "kmeans", + sensor_count: sensorCount, + min_diameter: minDiameter, + }); + open?.({ + type: "success", + message: "方案创建成功", + description: `方案 "${schemeName}" 已完成优化分析`, + }); + onSchemeCreated?.(created); + setFormField("schemeName", "Fangan" + new Date().getTime()); } catch (error: any) { console.error("创建方案失败:", error); open?.({ @@ -175,11 +128,10 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({ 类型 </Typography> <TextField - select fullWidth size="small" - value={sensorType} - onChange={(e) => setFormField("sensorType", e.target.value)} + value="压力" + slotProps={{ input: { readOnly: true } }} sx={{ "& .MuiOutlinedInput-root": { "&:hover fieldset": { @@ -190,13 +142,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({ }, }, }} - > - {sensorTypeOptions.map((option) => ( - <MenuItem key={option.value} value={option.value}> - {option.label} - </MenuItem> - ))} - </TextField> + /> </Box> {/* 方法选择 */} @@ -270,7 +216,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({ variant="subtitle2" className="mb-2 font-semibold text-gray-700" > - {getSensorTypeLabel(sensorType)}监测点安装最小管径(可选) + 压力监测点安装最小管径(可选) </Typography> <TextField fullWidth diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx new file mode 100644 index 0000000..e88ce3a --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx @@ -0,0 +1,665 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Radio, + RadioGroup, + Typography, +} from "@mui/material"; +import { + Download as DownloadIcon, + Print as PrintIcon, +} from "@mui/icons-material"; +import OlMap from "ol/Map"; +import View from "ol/View"; +import LayerGroup from "ol/layer/Group"; +import TileLayer from "ol/layer/Tile"; +import VectorTileLayer from "ol/layer/VectorTile"; +import type TileSource from "ol/source/Tile"; +import type VectorTileSource from "ol/source/VectorTile"; +import type { StyleLike } from "ol/style/Style"; +import { + TileFeatureIndex, + clipLineStringPartsToExtent, + lineStringFromFlatCoordinates, +} from "@components/olmap/core/tileFeatureIndex"; +import { + A3_LANDSCAPE_WIDTH, + canvasToPngBlob, + getPaddedDrawingExtent, + renderEngineeringDrawing, +} from "./engineeringDrawing"; +import type { + NetworkDrawingData, + SensorPlacementScheme, + SensorPointRow, +} from "./types"; + +interface SchemeDrawingDialogProps { + open: boolean; + onClose: () => void; + scheme: SensorPlacementScheme; + rows: SensorPointRow[]; + network: string; + map: OlMap | null; + dirty: boolean; +} + +const DRAWING_MAP_RENDER_TIMEOUT_MS = 10_000; +const DRAWING_MAP_SIZE: [number, number] = [1600, 981]; + +type DrawingMode = "linework" | "basemap"; + +type DrawingPipeLayer = { + getSource?: () => VectorTileSource | null; + getExtent?: () => number[] | undefined; + getStyle?: () => StyleLike | null | undefined; +}; + +const getDrawingPipeLayer = (map: OlMap) => + map.getAllLayers().find((layer) => layer.get("value") === "pipes") as + DrawingPipeLayer | undefined; + +export const fitMapToFullNetwork = ( + map: OlMap, +): [number, number, number, number] => { + const extent = getDrawingPipeLayer(map)?.getExtent?.(); + if ( + !extent || + extent.length !== 4 || + !extent.every(Number.isFinite) || + extent[0] >= extent[2] || + extent[1] >= extent[3] + ) { + throw new Error("主地图中未配置有效的管网完整范围"); + } + const size = map.getSize(); + if (!size) throw new Error("地图尺寸不可用"); + const networkExtent: [number, number, number, number] = [ + extent[0], + extent[1], + extent[2], + extent[3], + ]; + const view = map.getView(); + const drawingExtent = getPaddedDrawingExtent( + networkExtent, + size[0] / size[1], + ); + view.cancelAnimations(); + view.fit(drawingExtent, { + padding: [0, 0, 0, 0], + size, + }); + return networkExtent; +}; + +interface DrawingMapSession { + map: OlMap; + extent: [number, number, number, number]; + dispose: () => void; +} + +const cloneVisibleBaseLayers = (mainMap: OlMap): TileLayer<TileSource>[] => { + const cloned: TileLayer<TileSource>[] = []; + const collect = (layer: unknown, parentVisible = true) => { + const candidate = layer as { + getVisible?: () => boolean; + getLayers?: () => { getArray: () => unknown[] }; + }; + if (!parentVisible || candidate.getVisible?.() === false) return; + if (layer instanceof LayerGroup) { + candidate.getLayers?.().getArray().forEach((child) => collect(child)); + return; + } + if (!(layer instanceof TileLayer)) return; + const source = layer.getSource(); + if (!source) return; + cloned.push( + new TileLayer({ + source, + opacity: layer.getOpacity(), + visible: true, + }), + ); + }; + mainMap.getLayers().getArray().forEach((layer) => collect(layer)); + return cloned; +}; + +const createDrawingMapSession = ( + mainMap: OlMap, + mode: DrawingMode, +): DrawingMapSession => { + const mainPipeLayer = getDrawingPipeLayer(mainMap); + const source = mainPipeLayer?.getSource?.(); + const extent = mainPipeLayer?.getExtent?.(); + if (!source) throw new Error("主地图中未找到管网图层"); + if ( + !extent || + extent.length !== 4 || + !extent.every(Number.isFinite) || + extent[0] >= extent[2] || + extent[1] >= extent[3] + ) { + throw new Error("主地图中未配置有效的管网完整范围"); + } + + const target = document.createElement("div"); + target.setAttribute("aria-hidden", "true"); + Object.assign(target.style, { + position: "fixed", + left: "-100000px", + top: "0", + width: `${DRAWING_MAP_SIZE[0]}px`, + height: `${DRAWING_MAP_SIZE[1]}px`, + pointerEvents: "none", + }); + document.body.appendChild(target); + + let drawingMap: OlMap | null = null; + try { + const networkExtent: [number, number, number, number] = [ + extent[0], + extent[1], + extent[2], + extent[3], + ]; + const pipeLayer = new VectorTileLayer({ + source, + style: mainPipeLayer?.getStyle?.(), + }); + pipeLayer.set("value", "pipes"); + pipeLayer.setExtent(networkExtent); + const layers = + mode === "basemap" + ? [...cloneVisibleBaseLayers(mainMap), pipeLayer] + : [pipeLayer]; + drawingMap = new OlMap({ + target, + view: new View({ + projection: mainMap.getView().getProjection(), + }), + layers, + controls: [], + interactions: [], + }); + drawingMap.setSize(DRAWING_MAP_SIZE); + fitMapToFullNetwork(drawingMap); + + let disposed = false; + return { + map: drawingMap, + extent: networkExtent, + dispose: () => { + if (disposed) return; + disposed = true; + drawingMap?.setTarget(undefined); + drawingMap?.getLayers().clear(); + target.remove(); + }, + }; + } catch (reason) { + drawingMap?.setTarget(undefined); + target.remove(); + throw reason; + } +}; + +const waitForDrawingMapRender = (map: OlMap) => + new Promise<void>((resolve, reject) => { + let timeout: ReturnType<typeof setTimeout>; + const cleanup = () => { + clearTimeout(timeout); + map.un("moveend", requestRender); + map.un("rendercomplete", handleRenderComplete); + }; + const handleRenderComplete = () => { + cleanup(); + resolve(); + }; + const requestRender = () => { + map.once("rendercomplete", handleRenderComplete); + map.render(); + }; + + timeout = setTimeout(() => { + cleanup(); + reject(new Error("工程图管网加载超时,请稍后重试")); + }, DRAWING_MAP_RENDER_TIMEOUT_MS); + + if (map.getView().getAnimating()) { + map.once("moveend", requestRender); + } else { + requestRender(); + } + }); + +const captureMapCanvas = (map: OlMap): HTMLCanvasElement => { + const size = map.getSize(); + if (!size) throw new Error("工程图地图尺寸不可用"); + const output = document.createElement("canvas"); + output.width = size[0]; + output.height = size[1]; + const context = output.getContext("2d"); + if (!context) throw new Error("无法创建地图底图画布"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, output.width, output.height); + + const canvases = map + .getViewport() + .querySelectorAll<HTMLCanvasElement>(".ol-layer canvas, canvas.ol-layer"); + canvases.forEach((canvas) => { + if (!canvas.width || !canvas.height) return; + const opacityText = + canvas.parentElement?.style.opacity || canvas.style.opacity || "1"; + const opacity = Number(opacityText); + context.globalAlpha = Number.isFinite(opacity) ? opacity : 1; + + const matrix = canvas.style.transform + .match(/^matrix\(([^)]+)\)$/)?.[1] + .split(",") + .map(Number); + if (matrix?.length === 6 && matrix.every(Number.isFinite)) { + context.setTransform( + matrix[0], + matrix[1], + matrix[2], + matrix[3], + matrix[4], + matrix[5], + ); + } else { + const width = Number.parseFloat(canvas.style.width) || canvas.width; + const height = Number.parseFloat(canvas.style.height) || canvas.height; + context.setTransform( + width / canvas.width, + 0, + 0, + height / canvas.height, + 0, + 0, + ); + } + context.drawImage(canvas, 0, 0); + }); + context.setTransform(1, 0, 0, 1, 0, 0); + context.globalAlpha = 1; + return output; +}; + +const readNetworkDataFromMap = ( + map: OlMap, + extent: [number, number, number, number], +): NetworkDrawingData => { + const pipeLayer = getDrawingPipeLayer(map); + const source = pipeLayer?.getSource?.(); + if (!source) throw new Error("工程图中未找到管网图层"); + + const index = new TileFeatureIndex("engineering-drawing-pipes", source); + index.scanLoadedTiles(); + const snapshot = index.getSnapshot(map, map.getView().getZoom() ?? 0); + const paths = snapshot.instances.flatMap((instance) => { + if (!instance.geometryType.includes("LineString")) return []; + return clipLineStringPartsToExtent( + lineStringFromFlatCoordinates(instance.flatCoordinates, instance.stride), + instance.tileExtent, + ); + }); + if (!paths.length) throw new Error("工程图管网尚未加载完成,请稍后重试"); + + const nodes: string[] = []; + const links: string[] = []; + const nodeIds = new globalThis.Map<string, string>(); + const getNodeId = ([x, y]: number[]) => { + const key = `${x}:${y}`; + const existing = nodeIds.get(key); + if (existing) return existing; + const nodeId = `drawing_node_${nodeIds.size + 1}`; + nodeIds.set(key, nodeId); + nodes.push(`${nodeId}:junction:${x}:${y}`); + return nodeId; + }; + + paths.forEach((path) => { + for (let index = 1; index < path.length; index += 1) { + const startId = getNodeId(path[index - 1]); + const endId = getNodeId(path[index]); + links.push(`drawing_link_${links.length + 1}:pipe:${startId}:${endId}`); + } + }); + return { nodes, links, extent }; +}; + +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character] ?? character, + ); + +const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({ + open, + onClose, + scheme, + rows, + network, + map, + dirty, +}) => { + const [networkData, setNetworkData] = useState<NetworkDrawingData | null>( + null, + ); + const [drawingMode, setDrawingMode] = useState<DrawingMode>("linework"); + const [mapCanvas, setMapCanvas] = useState<HTMLCanvasElement | null>(null); + const [previewUrl, setPreviewUrl] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const filename = useMemo( + () => `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_布置图.png`, + [dirty, scheme.scheme_name], + ); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + setError(null); + setNetworkData(null); + setMapCanvas(null); + if (!map) { + setError("主地图尚未初始化,请稍后重试"); + setLoading(false); + return; + } + let drawingSession: DrawingMapSession; + try { + drawingSession = createDrawingMapSession(map, drawingMode); + } catch (reason) { + setError( + reason instanceof Error ? reason.message : "无法适配完整管网范围", + ); + setLoading(false); + return; + } + waitForDrawingMapRender(drawingSession.map) + .then(() => { + const data = readNetworkDataFromMap( + drawingSession.map, + drawingSession.extent, + ); + const captured = + drawingMode === "basemap" + ? captureMapCanvas(drawingSession.map) + : null; + return { data, captured }; + }) + .then(({ data, captured }) => { + if (!cancelled) { + setNetworkData(data); + setMapCanvas(captured); + } + }) + .catch((reason) => { + if (!cancelled) { + setError( + reason instanceof Error ? reason.message : "无法读取工程图管网数据", + ); + } + }) + .finally(() => { + drawingSession.dispose(); + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + drawingSession.dispose(); + }; + }, [drawingMode, map, network, open]); + + useEffect(() => { + if (!open || !networkData) return; + let cancelled = false; + let currentUrl: string | null = null; + setLoading(true); + setError(null); + renderEngineeringDrawing({ + scheme, + rows, + network, + networkData, + mapCanvas, + dirty, + width: 1600, + }) + .then(canvasToPngBlob) + .then((blob) => { + if (cancelled) return; + currentUrl = URL.createObjectURL(blob); + setPreviewUrl((previous) => { + if (previous) URL.revokeObjectURL(previous); + return currentUrl; + }); + }) + .catch((reason) => { + if (!cancelled) + setError(reason instanceof Error ? reason.message : "出图失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + if (currentUrl) URL.revokeObjectURL(currentUrl); + }; + }, [dirty, mapCanvas, network, networkData, open, rows, scheme]); + + const createFullResolutionBlob = async () => { + if (!networkData) throw new Error("管网数据尚未加载"); + const canvas = await renderEngineeringDrawing({ + scheme, + rows, + network, + networkData, + mapCanvas, + dirty, + width: A3_LANDSCAPE_WIDTH, + }); + return canvasToPngBlob(canvas); + }; + + const handleDownload = async () => { + setLoading(true); + setError(null); + try { + downloadBlob(await createFullResolutionBlob(), filename); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "PNG 下载失败"); + } finally { + setLoading(false); + } + }; + + const handlePrint = async () => { + const printWindow = window.open("", "_blank"); + if (!printWindow) { + setError("浏览器阻止了打印窗口,请允许本站打开新窗口"); + return; + } + printWindow.opener = null; + printWindow.document.write( + '<!doctype html><html lang="zh-CN"><body style="font-family: sans-serif; padding: 24px;">正在生成 A3 工程图...</body></html>', + ); + printWindow.document.close(); + setLoading(true); + setError(null); + try { + const blob = await createFullResolutionBlob(); + const url = URL.createObjectURL(blob); + const safeSchemeName = escapeHtml(scheme.scheme_name); + printWindow.document.open(); + printWindow.document.write(` + <!doctype html> + <html lang="zh-CN"> + <head> + <title>${safeSchemeName} 布置图 + + + ${safeSchemeName} 压力监测点布置图 + + `); + printWindow.document.close(); + const image = printWindow.document.querySelector("img"); + image?.addEventListener("load", () => { + printWindow.focus(); + printWindow.print(); + URL.revokeObjectURL(url); + }); + } catch (reason) { + printWindow.close(); + setError(reason instanceof Error ? reason.message : "打印失败"); + } finally { + setLoading(false); + } + }; + + return ( + + + 工程布置图预览 + + + + + setDrawingMode(event.target.value as DrawingMode) + } + aria-label="工程图底图模式" + > + } + label="简化管网线稿" + /> + } + label="地图底图 + 管网" + /> + + + A3 横向 · 300 DPI · 4961 × 3508 + + + {error && ( + + {error} + + )} + + {previewUrl && ( + + )} + {loading && ( + + + + )} + + + + + + + + + ); +}; + +export default SchemeDrawingDialog; diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx new file mode 100644 index 0000000..993874f --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx @@ -0,0 +1,804 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Alert, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import { + AddLocationAlt as AddIcon, + DeleteOutline as DeleteIcon, + Download as DownloadIcon, + EditLocationAlt as ReplaceIcon, + LocationOn as LocateIcon, + Map as MapIcon, + Redo as ResetIcon, + Save as SaveIcon, + Undo as UndoIcon, +} from "@mui/icons-material"; +import { + DataGrid, + GridToolbar, + type GridColDef, + type GridRowSelectionModel, +} from "@mui/x-data-grid"; +import { zhCN } from "@mui/x-data-grid/locales"; +import Feature, { type FeatureLike } from "ol/Feature"; +import Point from "ol/geom/Point"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { Circle, Fill, Stroke, Style, Text } from "ol/style"; +import { fromLonLat, toLonLat } from "ol/proj"; +import { useNotification } from "@refinedev/core"; +import { api } from "@/lib/api"; +import { config } from "@/config/config"; +import { useMap } from "@components/olmap/core/MapComponent"; +import { handleMapClickSelectFeatures } from "@/utils/mapQueryService"; +import { + addSensorPoint, + createSchemeEditorState, + deleteSensorPoint, + isSchemeDirty, + replaceSensorPoint, + resetSchemeEdit, + summarizeChanges, + toSensorPointRows, + undoSchemeEdit, +} from "./schemeEditor"; +import { + exportSensorPlacementExcel, + overwriteSensorPlacementScheme, +} from "./schemeApi"; +import SchemeDrawingDialog from "./SchemeDrawingDialog"; +import type { + AdjustmentStatus, + SensorPlacementScheme, + SensorPoint, + SensorPointRow, +} from "./types"; + +type EditMode = "idle" | "add" | "replace" | "delete"; + +interface SchemeEditorProps { + scheme: SensorPlacementScheme; + network: string; + active?: boolean; + onSaved?: (scheme: SensorPlacementScheme) => void; +} + +const STATUS_LABELS: Record = { + current: "当前方案", + original: "原方案", + added: "新增", + replaced: "替换", +}; + +const STATUS_COLORS: Record< + AdjustmentStatus, + "default" | "success" | "warning" | "info" +> = { + current: "default", + original: "info", + added: "success", + replaced: "warning", +}; + +const markerStyle = (feature: FeatureLike) => { + const status = feature.get("adjustment_status") as AdjustmentStatus; + const selected = Boolean(feature.get("selected")); + const color = + status === "added" ? "#16865b" : status === "replaced" ? "#d06b16" : "#c9252d"; + return new Style({ + image: new Circle({ + radius: selected ? 11 : 9, + fill: new Fill({ color: "#ffffff" }), + stroke: new Stroke({ color, width: selected ? 4 : 3 }), + }), + text: new Text({ + text: String(feature.get("sequence") ?? ""), + font: '700 11px -apple-system, "PingFang SC", sans-serif', + fill: new Fill({ color }), + offsetY: 0.5, + }), + zIndex: selected ? 20 : 10, + }); +}; + +const errorDescription = (error: unknown) => { + const candidate = error as { + response?: { data?: { detail?: string } }; + message?: string; + }; + return candidate.response?.data?.detail || candidate.message || "请求失败"; +}; + +const SchemeEditor: React.FC = ({ + scheme, + network, + active = true, + onSaved, +}) => { + const map = useMap(); + const { open } = useNotification(); + const [editor, setEditor] = useState(() => createSchemeEditorState(scheme)); + const [mode, setMode] = useState("idle"); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [replaceSourceId, setReplaceSourceId] = useState(null); + const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState(false); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [drawingOpen, setDrawingOpen] = useState(false); + const markerLayerRef = useRef | null>(null); + + useEffect(() => { + setEditor(createSchemeEditorState(scheme)); + setMode("idle"); + setReplaceSourceId(null); + setSelectedNodeId(null); + }, [scheme]); + + const rows = useMemo(() => toSensorPointRows(editor), [editor]); + const dirty = isSchemeDirty(editor); + const changes = summarizeChanges(editor); + + useEffect(() => { + if (!map) return; + const layer = new VectorLayer({ + source: new VectorSource(), + style: markerStyle, + properties: { + name: "监测点方案编辑", + value: "sensor_scheme_editor", + queryable: false, + }, + zIndex: 120, + }); + markerLayerRef.current = layer; + map.addLayer(layer); + return () => { + markerLayerRef.current = null; + map.removeLayer(layer); + }; + }, [map]); + + useEffect(() => { + markerLayerRef.current?.setVisible(active); + }, [active]); + + useEffect(() => { + const source = markerLayerRef.current?.getSource(); + if (!source) return; + source.clear(); + rows.forEach((row) => { + const feature = new Feature({ + geometry: new Point([row.map_x, row.map_y]), + node_id: row.node_id, + sequence: row.sequence, + adjustment_status: row.adjustment_status, + selected: row.node_id === selectedNodeId, + }); + feature.setId(`sensor-scheme-${row.node_id}`); + source.addFeature(feature); + }); + }, [rows, selectedNodeId]); + + const resolveJunction = useCallback( + async (event: any): Promise => { + if (!map) return null; + const feature = await handleMapClickSelectFeatures(event, map); + const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim(); + if (!nodeId) return null; + const featureGeometry = feature?.getGeometry(); + const featureCoordinate = + featureGeometry instanceof Point + ? featureGeometry.getCoordinates() + : event.coordinate; + const projectedFeatureCoordinate = + Math.abs(featureCoordinate[0]) <= 180 && + Math.abs(featureCoordinate[1]) <= 90 + ? fromLonLat(featureCoordinate) + : featureCoordinate; + const resolution = map.getView().getResolution() ?? 1; + const isAlignedWithMap = + Math.hypot( + projectedFeatureCoordinate[0] - event.coordinate[0], + projectedFeatureCoordinate[1] - event.coordinate[1], + ) <= + resolution * 20; + const [mapX, mapY] = isAlignedWithMap + ? projectedFeatureCoordinate + : event.coordinate; + try { + const response = await api.get<{ + id: string; + x: number; + y: number; + elevation: number; + }>(`${config.BACKEND_URL}/api/v1/getjunctionproperties/`, { + params: { network, junction: nodeId }, + }); + if (!response.data?.id) return null; + const [longitude, latitude] = toLonLat([mapX, mapY]); + return { + node_id: String(response.data.id), + project_x: Number(response.data.x), + project_y: Number(response.data.y), + map_x: mapX, + map_y: mapY, + longitude, + latitude, + elevation: Number(response.data.elevation), + }; + } catch { + return null; + } + }, + [map, network], + ); + + useEffect(() => { + if (!active || !map || mode === "idle" || !scheme.can_edit) return; + let handling = false; + const processClick = async (event: any) => { + if (handling) return; + event.stopPropagation?.(); + handling = true; + try { + const markerLayer = markerLayerRef.current; + const marker = markerLayer + ? map.forEachFeatureAtPixel( + event.pixel, + (feature) => feature as Feature, + { + hitTolerance: 8, + layerFilter: (layer) => layer === markerLayer, + }, + ) + : undefined; + const markerNodeId = marker ? String(marker.get("node_id")) : null; + + if (mode === "delete") { + if (!markerNodeId) { + open?.({ type: "progress", message: "请点击要删除的监测点" }); + return; + } + setEditor((current) => deleteSensorPoint(current, markerNodeId)); + setSelectedNodeId(null); + return; + } + + if (mode === "replace" && !replaceSourceId) { + if (!markerNodeId) { + open?.({ type: "progress", message: "请先点击要替换的监测点" }); + return; + } + setReplaceSourceId(markerNodeId); + setSelectedNodeId(markerNodeId); + return; + } + + const candidate = await resolveJunction(event); + if (!candidate) { + open?.({ type: "progress", message: "请选择有效的管网节点" }); + return; + } + + if (mode === "add") { + const exists = editor.points.some( + (point) => point.node_id === candidate.node_id, + ); + if (exists) { + open?.({ type: "progress", message: "该节点已在当前方案中" }); + return; + } + setEditor((current) => addSensorPoint(current, candidate)); + setSelectedNodeId(candidate.node_id); + } else if (mode === "replace" && replaceSourceId) { + const duplicate = editor.points.some( + (point) => + point.node_id === candidate.node_id && + point.node_id !== replaceSourceId, + ); + if (duplicate) { + open?.({ type: "progress", message: "目标节点已在当前方案中" }); + return; + } + setEditor((current) => + replaceSensorPoint(current, replaceSourceId, candidate), + ); + setSelectedNodeId(candidate.node_id); + setReplaceSourceId(null); + setMode("idle"); + } + } finally { + handling = false; + } + }; + const handleClick = (event: any) => { + void processClick(event); + }; + map.on("singleclick", handleClick); + return () => { + map.un("singleclick", handleClick); + }; + }, [ + editor.points, + active, + map, + mode, + network, + open, + replaceSourceId, + resolveJunction, + scheme.can_edit, + ]); + + const activateMode = useCallback( + (nextMode: EditMode, sourceNodeId?: string) => { + setMode((current) => + current === nextMode && !sourceNodeId ? "idle" : nextMode, + ); + setReplaceSourceId(sourceNodeId ?? null); + if (sourceNodeId) setSelectedNodeId(sourceNodeId); + }, + [], + ); + + const handleDelete = useCallback((nodeId: string) => { + setEditor((current) => deleteSensorPoint(current, nodeId)); + if (selectedNodeId === nodeId) setSelectedNodeId(null); + }, [selectedNodeId]); + + const locateRow = useCallback((row: SensorPointRow) => { + setSelectedNodeId(row.node_id); + if (!map) return; + map.getView().animate({ + center: [row.map_x, row.map_y], + zoom: Math.max(map.getView().getZoom() ?? 16, 18), + duration: 300, + }); + }, [map]); + + const handleSave = async () => { + setSaving(true); + try { + const updated = await overwriteSensorPlacementScheme( + network, + scheme.id, + editor.baseline.map((point) => point.node_id), + editor.points.map((point) => point.node_id), + ); + setEditor(createSchemeEditorState(updated)); + onSaved?.(updated); + setSaveDialogOpen(false); + open?.({ + type: "success", + message: "方案已覆盖保存", + description: `当前共 ${updated.sensor_number} 个监测点`, + }); + } catch (error) { + open?.({ + type: "error", + message: + (error as { response?: { status?: number } }).response?.status === 409 + ? "方案已发生变化" + : "方案保存失败", + description: errorDescription(error), + }); + } finally { + setSaving(false); + } + }; + + const handleExport = async () => { + setExporting(true); + try { + const blob = await exportSensorPlacementExcel( + network, + scheme.id, + editor.points.map((point) => point.node_id), + editor.statuses, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_监测点清单.xlsx`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (error) { + open?.({ + type: "error", + message: "Excel 导出失败", + description: errorDescription(error), + }); + } finally { + setExporting(false); + } + }; + + const columns = useMemo[]>( + () => [ + { + field: "sequence", + headerName: "序号", + width: 64, + align: "center", + headerAlign: "center", + sortable: false, + }, + { field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 }, + { + field: "longitude", + headerName: "经度", + minWidth: 125, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(6), + }, + { + field: "latitude", + headerName: "纬度", + minWidth: 125, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(6), + }, + { + field: "project_x", + headerName: "工程 X", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "project_y", + headerName: "工程 Y", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "map_x", + headerName: "地图 X", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "map_y", + headerName: "地图 Y", + minWidth: 130, + flex: 1, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "elevation", + headerName: "高程", + width: 92, + align: "right", + headerAlign: "right", + valueFormatter: (value) => Number(value).toFixed(3), + }, + { + field: "adjustment_status", + headerName: "调整状态", + width: 108, + renderCell: ({ value }) => { + const status = value as AdjustmentStatus; + return ( + + ); + }, + }, + { + field: "actions", + headerName: "操作", + width: scheme.can_edit ? 138 : 54, + sortable: false, + filterable: false, + renderCell: ({ row }) => ( + + + locateRow(row)} + sx={{ width: 40, height: 40 }} + > + + + + {scheme.can_edit && ( + <> + + activateMode("replace", row.node_id)} + sx={{ width: 40, height: 40 }} + > + + + + + + handleDelete(row.node_id)} + disabled={rows.length <= 1} + sx={{ width: 40, height: 40 }} + > + + + + + + )} + + ), + }, + ], + [activateMode, handleDelete, locateRow, rows.length, scheme.can_edit], + ); + + const instruction = + mode === "add" + ? "点击地图中的管网节点添加监测点" + : mode === "replace" + ? replaceSourceId + ? `已选择 ${replaceSourceId},请点击新的管网节点` + : "请先点击要替换的监测点" + : mode === "delete" + ? "点击地图中的监测点删除" + : null; + + return ( + + + + + {scheme.scheme_name} + + + {scheme.username} · {rows.length} 个监测点 + {dirty ? " · 未保存草稿" : ""} + + + + + + {scheme.can_edit && ( + + )} + + + + {!scheme.can_edit && ( + 当前为只读方案,创建人或管理员可以微调并保存。 + )} + + {scheme.can_edit && ( + + + + + + + + setEditor((current) => undoSchemeEdit(current))} + sx={{ width: 40, height: 40 }} + > + + + + + + + setEditor((current) => resetSchemeEdit(current))} + sx={{ width: 40, height: 40 }} + > + + + + + + )} + + {instruction && ( + { + setMode("idle"); + setReplaceSourceId(null); + }} + > + 取消 + + } + > + {instruction} + + )} + + + row.node_id} + rowHeight={48} + columnHeaderHeight={44} + density="compact" + disableRowSelectionOnClick={false} + rowSelectionModel={selectedNodeId ? [selectedNodeId] : []} + onRowSelectionModelChange={(selection: GridRowSelectionModel) => + setSelectedNodeId(selection.length ? String(selection[0]) : null) + } + onRowDoubleClick={({ row }) => locateRow(row)} + slots={{ toolbar: GridToolbar }} + slotProps={{ + toolbar: { + showQuickFilter: true, + printOptions: { disableToolbarButton: true }, + csvOptions: { disableToolbarButton: true }, + }, + }} + sx={{ + borderColor: "rgba(15, 23, 42, 0.10)", + "& .MuiDataGrid-columnHeaders": { bgcolor: "#edf3f7" }, + "& .MuiDataGrid-cell": { + borderColor: "rgba(15, 23, 42, 0.06)", + fontVariantNumeric: "tabular-nums", + }, + "& .MuiDataGrid-row.Mui-selected": { + bgcolor: "rgba(37, 125, 212, 0.10)", + }, + }} + /> + + + !saving && setSaveDialogOpen(false)} + aria-labelledby="overwrite-scheme-title" + > + 覆盖当前方案 + + + 保存后将覆盖原方案节点。此次调整包含:新增 {changes.added} 个,替换{" "} + {changes.replaced} 个,删除 {changes.removed} 个,保存后共 {rows.length} 个。 + + + + + + + + + setDrawingOpen(false)} + scheme={scheme} + rows={rows} + network={network} + map={map ?? null} + dirty={dirty} + /> + + ); +}; + +export default SchemeEditor; diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 0036bd6..4aa4670 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -17,7 +17,7 @@ import { } from "@mui/material"; import { Info as InfoIcon, - LocationOn as LocationIcon, + EditLocationAlt as EditIcon, } from "@mui/icons-material"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; @@ -37,19 +37,10 @@ import VectorSource from "ol/source/Vector"; import { Style, Icon, Circle, Fill, Stroke } from "ol/style"; import Feature, { FeatureLike } from "ol/Feature"; import { bbox, featureCollection } from "@turf/turf"; +import type { SchemeRecord } from "./types"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState"; -interface SchemeRecord { - id: number; - schemeName: string; - sensorNumber: number; - minDiameter: number; - user: string; - create_time: string; - sensorLocation?: string[]; -} - interface SchemaItem { id: number; scheme_name: string; @@ -63,7 +54,7 @@ interface SchemaItem { interface SchemeQueryProps { schemes?: SchemeRecord[]; onSchemesChange?: (schemes: SchemeRecord[]) => void; - onLocate?: (id: number) => void; + onEdit?: (id: number) => void; network?: string; state?: MonitoringSchemeQueryState; onStateChange?: (state: MonitoringSchemeQueryState) => void; @@ -87,7 +78,7 @@ export const createMonitoringSchemeQueryState = const SchemeQuery: React.FC = ({ schemes: externalSchemes, onSchemesChange, - onLocate, + onEdit, network = NETWORK_NAME, state, onStateChange, @@ -205,7 +196,7 @@ const SchemeQuery: React.FC = ({ schemeName: item.scheme_name, sensorNumber: item.sensor_number, minDiameter: item.min_diameter, - user: item.username, + username: item.username, create_time: item.create_time, sensorLocation: item.sensor_location, })); @@ -275,15 +266,6 @@ const SchemeQuery: React.FC = ({ setQueryField("expandedId", expandedId === id ? null : id); }; - // 保存方案(示例功能) - const handleSaveScheme = (scheme: SchemeRecord) => { - open?.({ - type: "success", - message: "保存成功", - description: `方案 "${scheme.schemeName}" 已保存`, - }); - }; - return ( {/* 查询条件 - 单行布局 */} @@ -379,8 +361,8 @@ const SchemeQuery: React.FC = ({ variant="caption" className="text-gray-500 block" > - 最小半径: {scheme.minDiameter} · 用户:{" "} - {creatorName(scheme.user)} · 日期:{" "} + 最小管径: {scheme.minDiameter} · 用户:{" "} + {creatorName(scheme.username)} · 日期:{" "} {formatShortDate(scheme.create_time)}
@@ -405,16 +387,6 @@ const SchemeQuery: React.FC = ({ - {/* - onLocate?.(scheme.id)} - color="primary" - className="p-1" - > - - - */} @@ -445,7 +417,7 @@ const SchemeQuery: React.FC = ({ variant="caption" className="text-gray-600 min-w-[70px]" > - 最小半径: + 最小管径:
= ({ variant="caption" className="font-medium text-gray-900" > - {creatorName(scheme.user)} + {creatorName(scheme.username)} @@ -552,13 +524,14 @@ const SchemeQuery: React.FC = ({ fullWidth size="small" className="bg-blue-600 hover:bg-blue-700" - onClick={() => handleSaveScheme(scheme)} + startIcon={} + onClick={() => onEdit?.(scheme.id)} sx={{ textTransform: "none", fontWeight: 500, }} > - 保存方案 + 打开结果编辑 diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts new file mode 100644 index 0000000..a0243b1 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts @@ -0,0 +1,335 @@ +jest.mock("@components/olmap/core/tileFeatureIndex", () => ({ + TileFeatureIndex: jest.fn(), + clipLineStringPartsToExtent: jest.fn(), + lineStringFromFlatCoordinates: jest.fn(), +})); + +jest.mock("ol/View", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + cancelAnimations: jest.fn(), + fit: jest.fn(), + getAnimating: () => false, + getProjection: () => "EPSG:3857", + getZoom: () => 12, + })), +})); + +jest.mock("ol/layer/VectorTile", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(({ source }) => { + let extent: number[] | undefined; + const properties = new Map(); + return { + get: (key: string) => properties.get(key), + getExtent: () => extent, + getSource: () => source, + set: (key: string, value: unknown) => properties.set(key, value), + setExtent: (value: number[]) => { + extent = value; + }, + }; + }), +})); + +jest.mock("ol/layer/Group", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("ol/layer/Tile", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(({ source }) => ({ + getOpacity: () => 1, + getSource: () => source, + })), +})); + +jest.mock("ol/Map", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(({ layers, view }) => { + let size: number[] | undefined; + let renderComplete: (() => void) | undefined; + return { + getAllLayers: () => layers, + getLayers: () => ({ clear: jest.fn() }), + getSize: () => size, + getView: () => view, + once: (event: string, callback: () => void) => { + if (event === "rendercomplete") renderComplete = callback; + }, + render: () => renderComplete?.(), + setSize: (value: number[]) => { + size = value; + }, + setTarget: jest.fn(), + un: jest.fn(), + }; + }), +})); + +import { render, screen, waitFor } from "@testing-library/react"; +import { createElement } from "react"; +import { + getPaddedDrawingExtent, + renderEngineeringDrawing, +} from "./engineeringDrawing"; +import SchemeDrawingDialog, { + fitMapToFullNetwork, +} from "./SchemeDrawingDialog"; +import type { SensorPlacementScheme, SensorPointRow } from "./types"; + +const createContext = () => ({ + arc: jest.fn(), + beginPath: jest.fn(), + clip: jest.fn(), + closePath: jest.fn(), + drawImage: jest.fn(), + fill: jest.fn(), + fillRect: jest.fn(), + fillText: jest.fn(), + lineTo: jest.fn(), + moveTo: jest.fn(), + rect: jest.fn(), + restore: jest.fn(), + save: jest.fn(), + setTransform: jest.fn(), + stroke: jest.fn(), + strokeRect: jest.fn(), + strokeText: jest.fn(), +}); + +const point = ( + node_id: string, + sequence: number, + map_x: number, + map_y: number, +): SensorPointRow => ({ + node_id, + sequence, + map_x, + map_y, + project_x: map_x, + project_y: map_y, + longitude: 121, + latitude: 31, + elevation: 5, + adjustment_status: "current", +}); + +const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)]; + +const scheme: SensorPlacementScheme = { + id: 1, + scheme_name: "测试方案", + sensor_number: rows.length, + min_diameter: 300, + username: "tester", + create_time: "2026-07-30T08:00:00+08:00", + sensor_location: rows.map((row) => row.node_id), + sensor_points: rows, + can_edit: true, +}; + +describe("engineering drawing map framing", () => { + it("does not change the visible main-map view while opening the drawing", async () => { + const cancelAnimations = jest.fn(); + const fit = jest.fn(); + const setCenter = jest.fn(); + const setResolution = jest.fn(); + const setRotation = jest.fn(); + const getAllLayers = jest.fn(() => [ + { + get: (key: string) => (key === "value" ? "pipes" : undefined), + getExtent: () => [0, 0, 1000, 1000], + getSource: () => ({}), + }, + ]); + let renderComplete: (() => void) | undefined; + const map = { + getAllLayers, + getSize: () => [1200, 800], + getView: () => ({ + cancelAnimations, + fit, + getAnimating: () => false, + getCenter: () => [500, 500], + getResolution: () => 10, + getRotation: () => 0, + getProjection: () => "EPSG:3857", + getZoom: () => 12, + setCenter, + setResolution, + setRotation, + }), + once: (event: string, callback: () => void) => { + if (event === "rendercomplete") renderComplete = callback; + }, + un: jest.fn(), + render: jest.fn(() => renderComplete?.()), + }; + + render( + createElement(SchemeDrawingDialog, { + open: true, + onClose: jest.fn(), + scheme, + rows, + network: "test", + map: map as never, + dirty: false, + }), + ); + + await waitFor(() => expect(getAllLayers).toHaveBeenCalled()); + expect(screen.getByLabelText("简化管网线稿")).toBeChecked(); + expect(screen.getByLabelText("地图底图 + 管网")).toBeInTheDocument(); + expect(fit).not.toHaveBeenCalled(); + expect(setCenter).not.toHaveBeenCalled(); + expect(setResolution).not.toHaveBeenCalled(); + expect(setRotation).not.toHaveBeenCalled(); + }); + + it("fits the OpenLayers map to the complete pipe layer extent", () => { + const extent = [13500000, 3600000, 13600000, 3700000]; + const cancelAnimations = jest.fn(); + const fit = jest.fn(); + const map = { + getAllLayers: () => [ + { + get: (key: string) => (key === "value" ? "pipes" : undefined), + getExtent: () => extent, + }, + ], + getSize: () => [1200, 800], + getView: () => ({ cancelAnimations, fit }), + }; + + expect(fitMapToFullNetwork(map as never)).toEqual(extent); + expect(cancelAnimations).toHaveBeenCalledTimes(1); + expect(fit).toHaveBeenCalledWith( + getPaddedDrawingExtent(extent, 1200 / 800), + { + padding: [0, 0, 0, 0], + size: [1200, 800], + }, + ); + expect(cancelAnimations.mock.invocationCallOrder[0]).toBeLessThan( + fit.mock.invocationCallOrder[0], + ); + }); +}); + +describe("engineering drawing", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("draws the complete network even when sensors occupy a local area", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:8000:4000", "B:junction:9000:4500"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 10000, 5000], + }, + dirty: false, + width: 1600, + }); + + // The first path is the remote pipe. A sensor-derived extent would skip it, + // leaving the title-block divider as the first path near the sheet bottom. + const [pipeStartX, pipeStartY] = context.moveTo.mock.calls[0]; + const [pipeEndX, pipeEndY] = context.lineTo.mock.calls[0]; + expect(pipeStartX).toBeGreaterThan(1200); + expect(pipeStartY).toBeLessThan(400); + expect(pipeEndX).toBeGreaterThan(pipeStartX); + expect(pipeEndY).toBeLessThan(pipeStartY); + }); + + it("uses one scale for linework coordinates", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:400:200", "B:junction:500:300"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 1000, 1000], + }, + dirty: false, + width: 1600, + }); + + const [startX, startY] = context.moveTo.mock.calls[0]; + const [endX, endY] = context.lineTo.mock.calls[0]; + expect(Math.abs(endX - startX)).toBeCloseTo(Math.abs(endY - startY), 6); + }); + + it("keeps visible space on the left and right of the complete network", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:0:0", "B:junction:1631:1000"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 1631, 1000], + }, + dirty: false, + width: 1600, + }); + + const [startX] = context.moveTo.mock.calls[0]; + const [endX] = context.lineTo.mock.calls[0]; + expect(startX).toBeGreaterThan(100); + expect(endX).toBeLessThan(1500); + }); + + it("uses the captured OpenLayers map for the basemap drawing mode", async () => { + const context = createContext(); + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + const mapCanvas = document.createElement("canvas"); + + await renderEngineeringDrawing({ + scheme, + rows, + network: "test", + networkData: { + nodes: ["A:junction:0:0", "B:junction:1000:1000"], + links: ["P1:pipe:A:B"], + extent: [0, 0, 1000, 1000], + }, + mapCanvas, + dirty: false, + width: 1600, + }); + + expect(context.drawImage).toHaveBeenCalledWith( + mapCanvas, + expect.any(Number), + expect.any(Number), + expect.any(Number), + expect.any(Number), + ); + }); +}); diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts new file mode 100644 index 0000000..d1d6fcd --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts @@ -0,0 +1,382 @@ +import type { + NetworkDrawingData, + SensorPlacementScheme, + SensorPointRow, +} from "./types"; + +export const A3_LANDSCAPE_WIDTH = 4961; +export const A3_LANDSCAPE_HEIGHT = 3508; + +interface DrawingOptions { + scheme: SensorPlacementScheme; + rows: SensorPointRow[]; + network: string; + networkData: NetworkDrawingData; + mapCanvas?: HTMLCanvasElement | null; + dirty: boolean; + width?: number; +} + +interface ParsedNode { + id: string; + x: number; + y: number; +} + +type DrawingExtent = [number, number, number, number]; +export const DRAWING_CONTENT_PADDING_RATIO = 0.04; + +const toDrawingExtent = (extent: number[]): DrawingExtent => { + if ( + extent.length !== 4 || + !extent.every(Number.isFinite) || + extent[0] >= extent[2] || + extent[1] >= extent[3] + ) { + throw new Error("地图范围不可用"); + } + return [extent[0], extent[1], extent[2], extent[3]]; +}; + +const parseNode = (value: string): ParsedNode | null => { + const [id, , x, y] = value.split(":"); + const parsedX = Number(x); + const parsedY = Number(y); + if (!id || !Number.isFinite(parsedX) || !Number.isFinite(parsedY)) + return null; + return { id, x: parsedX, y: parsedY }; +}; + +const fitExtentToAspectRatio = ( + extent: DrawingExtent, + targetAspectRatio: number, +): DrawingExtent => { + const [minX, minY, maxX, maxY] = extent; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + let spanX = maxX - minX; + let spanY = maxY - minY; + + if (spanX / spanY > targetAspectRatio) { + spanY = spanX / targetAspectRatio; + } else { + spanX = spanY * targetAspectRatio; + } + + return [ + centerX - spanX / 2, + centerY - spanY / 2, + centerX + spanX / 2, + centerY + spanY / 2, + ]; +}; + +export const getPaddedDrawingExtent = ( + extent: number[], + targetAspectRatio: number, +): DrawingExtent => { + const fitted = fitExtentToAspectRatio( + toDrawingExtent(extent), + targetAspectRatio, + ); + const [minX, minY, maxX, maxY] = fitted; + const horizontalPadding = + ((maxX - minX) * DRAWING_CONTENT_PADDING_RATIO) / + (1 - DRAWING_CONTENT_PADDING_RATIO * 2); + const verticalPadding = + ((maxY - minY) * DRAWING_CONTENT_PADDING_RATIO) / + (1 - DRAWING_CONTENT_PADDING_RATIO * 2); + return [ + minX - horizontalPadding, + minY - verticalPadding, + maxX + horizontalPadding, + maxY + verticalPadding, + ]; +}; + +const drawText = ( + context: CanvasRenderingContext2D, + value: string, + x: number, + y: number, + size: number, + weight = 400, + align: CanvasTextAlign = "left", + outlineColor?: string, + outlineWidth = 0, +) => { + context.font = `${weight} ${size}px -apple-system, "PingFang SC", "Noto Sans SC", sans-serif`; + context.textAlign = align; + context.textBaseline = "middle"; + if (outlineColor && outlineWidth > 0) { + context.strokeStyle = outlineColor; + context.lineWidth = outlineWidth; + context.lineJoin = "round"; + context.strokeText(value, x, y); + } + context.fillText(value, x, y); +}; + +export const renderEngineeringDrawing = async ({ + scheme, + rows, + network, + networkData, + mapCanvas, + dirty, + width = A3_LANDSCAPE_WIDTH, +}: DrawingOptions): Promise => { + if (!rows.length) throw new Error("方案没有可出图的监测点"); + const height = Math.round((width * A3_LANDSCAPE_HEIGHT) / A3_LANDSCAPE_WIDTH); + const scale = width / A3_LANDSCAPE_WIDTH; + const px = (value: number) => value * scale; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("无法创建工程图画布"); + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, width, height); + context.strokeStyle = "#111827"; + context.lineWidth = px(8); + context.strokeRect(px(70), px(70), width - px(140), height - px(140)); + + const mapBox = { + x: px(150), + y: px(150), + width: width - px(300), + height: height - px(650), + }; + context.save(); + context.beginPath(); + context.rect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); + context.clip(); + + const extent = getPaddedDrawingExtent( + networkData.extent, + mapBox.width / mapBox.height, + ); + context.fillStyle = "#fbfcfd"; + context.fillRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); + if (mapCanvas) { + context.drawImage( + mapCanvas, + mapBox.x, + mapBox.y, + mapBox.width, + mapBox.height, + ); + } + + const nodes = networkData.nodes + .map(parseNode) + .filter((node): node is ParsedNode => Boolean(node)); + const nodesById = new globalThis.Map( + nodes.map((node) => [node.id, node] as const), + ); + const [minX, minY, maxX, maxY] = extent; + const toCanvas = (x: number, y: number) => ({ + x: mapBox.x + ((x - minX) / (maxX - minX)) * mapBox.width, + y: + mapBox.y + mapBox.height - ((y - minY) / (maxY - minY)) * mapBox.height, + }); + + if (!mapCanvas) { + context.strokeStyle = "#45677a"; + context.lineWidth = px(5); + for (const link of networkData.links) { + const [, , startId, endId] = link.split(":"); + const start = nodesById.get(startId); + const end = nodesById.get(endId); + if (!start || !end) continue; + if ( + Math.max(start.x, end.x) < minX || + Math.min(start.x, end.x) > maxX || + Math.max(start.y, end.y) < minY || + Math.min(start.y, end.y) > maxY + ) { + continue; + } + const startPoint = toCanvas(start.x, start.y); + const endPoint = toCanvas(end.x, end.y); + context.beginPath(); + context.moveTo(startPoint.x, startPoint.y); + context.lineTo(endPoint.x, endPoint.y); + context.stroke(); + } + + context.fillStyle = "#668697"; + for (const node of nodes) { + if (node.x < minX || node.x > maxX || node.y < minY || node.y > maxY) { + continue; + } + const point = toCanvas(node.x, node.y); + context.beginPath(); + context.arc(point.x, point.y, px(4), 0, Math.PI * 2); + context.fill(); + } + } + + rows.forEach((row) => { + const point = toCanvas(row.map_x, row.map_y); + context.fillStyle = "#ffffff"; + context.beginPath(); + context.arc(point.x, point.y, px(36), 0, Math.PI * 2); + context.fill(); + + context.strokeStyle = "rgba(255,255,255,0.96)"; + context.lineWidth = px(14); + context.stroke(); + context.strokeStyle = "#c9252d"; + context.lineWidth = px(5); + context.stroke(); + + context.fillStyle = "#a51f28"; + drawText( + context, + String(row.sequence), + point.x, + point.y, + px(32), + 700, + "center", + ); + + context.fillStyle = "#273746"; + drawText( + context, + row.node_id, + point.x, + point.y + px(62), + px(25), + 650, + "center", + "rgba(255,255,255,0.98)", + px(9), + ); + }); + context.restore(); + + context.strokeStyle = "#111827"; + context.lineWidth = px(4); + context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); + + for (let index = 0; index <= 4; index += 1) { + const ratio = index / 4; + const x = mapBox.x + ratio * mapBox.width; + const y = mapBox.y + mapBox.height - ratio * mapBox.height; + context.fillStyle = "#334155"; + drawText( + context, + (minX + ratio * (maxX - minX)).toFixed(0), + x, + mapBox.y + mapBox.height + px(32), + px(20), + 500, + "center", + ); + drawText( + context, + (minY + ratio * (maxY - minY)).toFixed(0), + mapBox.x - px(18), + y, + px(20), + 500, + "right", + ); + } + + const titleTop = height - px(420); + context.strokeStyle = "#111827"; + context.lineWidth = px(4); + context.strokeRect(px(150), titleTop, width - px(300), px(270)); + context.beginPath(); + context.moveTo(width - px(1700), titleTop); + context.lineTo(width - px(1700), titleTop + px(270)); + context.stroke(); + + context.fillStyle = "#111827"; + drawText( + context, + `${scheme.scheme_name} 压力监测点布置图`, + px(220), + titleTop + px(70), + px(48), + 700, + ); + drawText( + context, + `项目:${network} 监测点:${rows.length} 个 地图:EPSG:3857 经纬度:WGS84`, + px(220), + titleTop + px(145), + px(26), + 500, + ); + drawText( + context, + dirty ? "状态:未保存草稿" : "状态:当前方案", + px(220), + titleTop + px(215), + px(26), + dirty ? 700 : 500, + ); + + const infoX = width - px(1620); + drawText( + context, + `创建人:${scheme.username}`, + infoX, + titleTop + px(55), + px(24), + 500, + ); + drawText( + context, + `创建时间:${new Date(scheme.create_time).toLocaleString("zh-CN")}`, + infoX, + titleTop + px(115), + px(24), + 500, + ); + drawText( + context, + `制图时间:${new Date().toLocaleString("zh-CN")}`, + infoX, + titleTop + px(175), + px(24), + 500, + ); + drawText( + context, + "图幅:A3 横向 300 DPI", + infoX, + titleTop + px(235), + px(24), + 500, + ); + + context.strokeStyle = "#111827"; + context.lineWidth = px(5); + const northX = width - px(260); + const northY = px(270); + context.beginPath(); + context.moveTo(northX, northY - px(70)); + context.lineTo(northX - px(32), northY + px(35)); + context.lineTo(northX, northY + px(15)); + context.lineTo(northX + px(32), northY + px(35)); + context.closePath(); + context.stroke(); + context.fillStyle = "#111827"; + drawText(context, "N", northX, northY - px(110), px(30), 700, "center"); + + return canvas; +}; + +export const canvasToPngBlob = (canvas: HTMLCanvasElement) => + new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("PNG 生成失败")); + }, "image/png"); + }); diff --git a/src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts b/src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts new file mode 100644 index 0000000..6998797 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/schemeApi.ts @@ -0,0 +1,73 @@ +import { api } from "@/lib/api"; +import { config } from "@/config/config"; +import type { + AdjustmentStatus, + SensorPlacementScheme, +} from "./types"; + +export interface OptimizeSchemeInput { + network: string; + scheme_name: string; + sensor_type: "pressure"; + method: "sensitivity" | "kmeans"; + sensor_count: number; + min_diameter: number; +} + +export const optimizeSensorPlacement = async ( + input: OptimizeSchemeInput, +): Promise => { + const response = await api.post( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/optimize`, + input, + ); + return response.data; +}; + +export const getSensorPlacementScheme = async ( + network: string, + schemeId: number, +): Promise => { + const response = await api.get( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`, + { params: { network } }, + ); + return response.data; +}; + +export const overwriteSensorPlacementScheme = async ( + network: string, + schemeId: number, + expectedSensorLocation: string[], + sensorLocation: string[], +): Promise => { + const response = await api.put( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`, + { + expected_sensor_location: expectedSensorLocation, + sensor_location: sensorLocation, + }, + { params: { network } }, + ); + return response.data; +}; + +export const exportSensorPlacementExcel = async ( + network: string, + schemeId: number, + sensorLocation: string[], + adjustmentStatus: Record, +) => { + const response = await api.post( + `${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}/exports/excel`, + { + sensor_location: sensorLocation, + adjustment_status: adjustmentStatus, + }, + { + params: { network }, + responseType: "blob", + }, + ); + return response.data; +}; diff --git a/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts new file mode 100644 index 0000000..3f77cb9 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.test.ts @@ -0,0 +1,84 @@ +import { + addSensorPoint, + createSchemeEditorState, + deleteSensorPoint, + isSchemeDirty, + replaceSensorPoint, + resetSchemeEdit, + summarizeChanges, + undoSchemeEdit, +} from "./schemeEditor"; +import type { SensorPlacementScheme, SensorPoint } from "./types"; + +const point = (node_id: string): SensorPoint => ({ + node_id, + project_x: Number(node_id.slice(1)) * 10, + project_y: Number(node_id.slice(1)) * 20, + map_x: 13500000 + Number(node_id.slice(1)) * 10, + map_y: 3600000 + Number(node_id.slice(1)) * 20, + longitude: 121, + latitude: 31, + elevation: 5, +}); + +const scheme: SensorPlacementScheme = { + id: 1, + scheme_name: "测试方案", + sensor_number: 2, + min_diameter: 300, + username: "alice", + create_time: "2026-07-30T08:00:00+08:00", + sensor_location: ["J1", "J2"], + sensor_points: [point("J1"), point("J2")], + can_edit: true, +}; + +describe("scheme editor", () => { + it("adds unique nodes and can undo", () => { + const initial = createSchemeEditorState(scheme); + const added = addSensorPoint(initial, point("J3")); + + expect(added.points.map((item) => item.node_id)).toEqual(["J1", "J2", "J3"]); + expect(added.statuses.J3).toBe("added"); + expect(isSchemeDirty(added)).toBe(true); + expect(undoSchemeEdit(added).points).toEqual(initial.points); + }); + + it("replaces a node without changing row order", () => { + const initial = createSchemeEditorState(scheme); + const replaced = replaceSensorPoint(initial, "J1", point("J3")); + + expect(replaced.points.map((item) => item.node_id)).toEqual(["J3", "J2"]); + expect(replaced.statuses.J3).toBe("replaced"); + expect(summarizeChanges(replaced)).toEqual({ + added: 0, + removed: 0, + replaced: 1, + }); + }); + + it("keeps an added status when replacing a newly added node", () => { + const added = addSensorPoint(createSchemeEditorState(scheme), point("J3")); + const replaced = replaceSensorPoint(added, "J3", point("J4")); + + expect(replaced.statuses.J3).toBeUndefined(); + expect(replaced.statuses.J4).toBe("added"); + }); + + it("rejects duplicate replacements and deleting the final row", () => { + const initial = createSchemeEditorState(scheme); + expect(replaceSensorPoint(initial, "J1", point("J2"))).toBe(initial); + + const oneLeft = deleteSensorPoint(initial, "J1"); + expect(deleteSensorPoint(oneLeft, "J2")).toBe(oneLeft); + }); + + it("resets to the loaded baseline", () => { + const edited = addSensorPoint(createSchemeEditorState(scheme), point("J3")); + const reset = resetSchemeEdit(edited); + + expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]); + expect(isSchemeDirty(reset)).toBe(false); + expect(reset.history).toEqual([]); + }); +}); diff --git a/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts new file mode 100644 index 0000000..e3dab4a --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/schemeEditor.ts @@ -0,0 +1,158 @@ +import type { + AdjustmentStatus, + SensorPlacementScheme, + SensorPoint, + SensorPointRow, +} from "./types"; + +export interface SchemeEditorSnapshot { + points: SensorPoint[]; + statuses: Record; +} + +export interface SchemeEditorState extends SchemeEditorSnapshot { + baseline: SensorPoint[]; + history: SchemeEditorSnapshot[]; +} + +const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point })); + +const currentStatuses = (points: SensorPoint[]) => + Object.fromEntries(points.map((point) => [point.node_id, "current"])) as Record< + string, + AdjustmentStatus + >; + +export const createSchemeEditorState = ( + scheme: SensorPlacementScheme, +): SchemeEditorState => ({ + baseline: clonePoints(scheme.sensor_points), + points: clonePoints(scheme.sensor_points), + statuses: currentStatuses(scheme.sensor_points), + history: [], +}); + +const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({ + points: clonePoints(state.points), + statuses: { ...state.statuses }, +}); + +const withHistory = ( + state: SchemeEditorState, + points: SensorPoint[], + statuses: Record, +): SchemeEditorState => ({ + ...state, + points, + statuses, + history: [...state.history, snapshot(state)], +}); + +export const addSensorPoint = ( + state: SchemeEditorState, + point: SensorPoint, +): SchemeEditorState => { + if (state.points.some((item) => item.node_id === point.node_id)) return state; + return withHistory( + state, + [...state.points, { ...point }], + { ...state.statuses, [point.node_id]: "added" }, + ); +}; + +export const replaceSensorPoint = ( + state: SchemeEditorState, + sourceNodeId: string, + point: SensorPoint, +): SchemeEditorState => { + const sourceIndex = state.points.findIndex( + (item) => item.node_id === sourceNodeId, + ); + if (sourceIndex < 0) return state; + if ( + sourceNodeId !== point.node_id && + state.points.some((item) => item.node_id === point.node_id) + ) { + return state; + } + + const nextPoints = clonePoints(state.points); + nextPoints[sourceIndex] = { ...point }; + const nextStatuses = { ...state.statuses }; + const sourceStatus = nextStatuses[sourceNodeId] ?? "current"; + delete nextStatuses[sourceNodeId]; + const baselineIds = new Set(state.baseline.map((item) => item.node_id)); + nextStatuses[point.node_id] = + sourceStatus === "added" + ? "added" + : baselineIds.has(point.node_id) + ? "original" + : "replaced"; + return withHistory(state, nextPoints, nextStatuses); +}; + +export const deleteSensorPoint = ( + state: SchemeEditorState, + nodeId: string, +): SchemeEditorState => { + if (state.points.length <= 1) return state; + if (!state.points.some((item) => item.node_id === nodeId)) return state; + const nextStatuses = { ...state.statuses }; + delete nextStatuses[nodeId]; + return withHistory( + state, + state.points.filter((item) => item.node_id !== nodeId), + nextStatuses, + ); +}; + +export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => { + const previous = state.history[state.history.length - 1]; + if (!previous) return state; + return { + ...state, + points: clonePoints(previous.points), + statuses: { ...previous.statuses }, + history: state.history.slice(0, -1), + }; +}; + +export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState => ({ + ...state, + points: clonePoints(state.baseline), + statuses: currentStatuses(state.baseline), + history: [], +}); + +export const isSchemeDirty = (state: SchemeEditorState) => { + const baselineIds = state.baseline.map((point) => point.node_id); + const currentIds = state.points.map((point) => point.node_id); + return ( + baselineIds.length !== currentIds.length || + baselineIds.some((nodeId, index) => nodeId !== currentIds[index]) + ); +}; + +export const toSensorPointRows = ( + state: SchemeEditorState, +): SensorPointRow[] => + state.points.map((point, index) => ({ + ...point, + sequence: index + 1, + adjustment_status: state.statuses[point.node_id] ?? "current", + })); + +export const summarizeChanges = (state: SchemeEditorState) => { + const baselineIds = new Set(state.baseline.map((point) => point.node_id)); + const currentIds = new Set(state.points.map((point) => point.node_id)); + const added = [...currentIds].filter((nodeId) => !baselineIds.has(nodeId)).length; + const removed = [...baselineIds].filter( + (nodeId) => !currentIds.has(nodeId), + ).length; + const replaced = Math.min(added, removed); + return { + added: Math.max(0, added - replaced), + removed: Math.max(0, removed - replaced), + replaced, + }; +}; diff --git a/src/components/olmap/MonitoringPlaceOptimization/types.ts b/src/components/olmap/MonitoringPlaceOptimization/types.ts new file mode 100644 index 0000000..1c3b058 --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/types.ts @@ -0,0 +1,45 @@ +export type AdjustmentStatus = "current" | "original" | "added" | "replaced"; + +export interface SensorPoint { + node_id: string; + project_x: number; + project_y: number; + map_x: number; + map_y: number; + longitude: number; + latitude: number; + elevation: number; +} + +export interface SensorPlacementScheme { + id: number; + scheme_name: string; + sensor_number: number; + min_diameter: number; + username: string; + create_time: string; + sensor_location: string[]; + sensor_points: SensorPoint[]; + can_edit: boolean; +} + +export interface SchemeRecord { + id: number; + schemeName: string; + sensorNumber: number; + minDiameter: number; + username: string; + create_time: string; + sensorLocation?: string[]; +} + +export interface SensorPointRow extends SensorPoint { + sequence: number; + adjustment_status: AdjustmentStatus; +} + +export interface NetworkDrawingData { + nodes: string[]; + links: string[]; + extent: [number, number, number, number]; +} diff --git a/src/components/olmap/core/Controls/BaseLayers.test.ts b/src/components/olmap/core/Controls/BaseLayers.test.ts index facafef..5008031 100644 --- a/src/components/olmap/core/Controls/BaseLayers.test.ts +++ b/src/components/olmap/core/Controls/BaseLayers.test.ts @@ -3,7 +3,7 @@ jest.mock("../MapComponent", () => ({ useMap: jest.fn(), })); jest.mock("../mapLifecycle", () => ({ - markMapResourcePersistent: (resource: T) => resource, + markMapResourcePersistent: (resource: T) => resource, })); jest.mock("ol/source/XYZ.js", () => ({ @@ -19,7 +19,9 @@ jest.mock("ol/layer/Tile.js", () => ({ constructor(options: any) { this.source = options.source; } - getSource() { return this.source; } + getSource() { + return this.source; + } }, })); jest.mock("ol/layer/Group", () => ({ @@ -29,14 +31,13 @@ jest.mock("ol/layer/Group", () => ({ constructor(options: any) { this.layers = options.layers; } - getLayers() { return { getArray: () => this.layers }; } + getLayers() { + return { getArray: () => this.layers }; + } }, })); -import { - createBaseLayerEntries, - createBaseLayerSources, -} from "./BaseLayers"; +import { createBaseLayerEntries, createBaseLayerSources } from "./BaseLayers"; const getLeafSources = (layer: any): unknown[] => { const childLayers = layer.getLayers?.().getArray?.(); @@ -47,6 +48,17 @@ const getLeafSources = (layer: any): unknown[] => { }; describe("base layer resources", () => { + it("loads every tile source with anonymous CORS for canvas export", () => { + const sources = createBaseLayerSources(); + + Object.values(sources).forEach((source) => { + expect( + (source as unknown as { options: { crossOrigin?: string } }).options + .crossOrigin, + ).toBe("anonymous"); + }); + }); + it("creates independent layers backed by one shared source pool", () => { const sources = createBaseLayerSources(); const primary = createBaseLayerEntries(sources); diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 64483a6..ebb001b 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -33,6 +33,7 @@ const BASE_LAYER_METADATA = [ const createTileSource = (url: string, attributions: string) => new XYZ({ url, + crossOrigin: "anonymous", tileSize: 512, maxZoom: 20, projection: "EPSG:3857", @@ -57,24 +58,28 @@ export const createBaseLayerSources = () => ({ '数据来源:Mapbox & OpenStreetMap', ), tiandituVector: new XYZ({ - url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), tiandituVectorAnnotation: new XYZ({ - url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), tiandituImage: new XYZ({ - url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), tiandituImageAnnotation: new XYZ({ - url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', + url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + crossOrigin: "anonymous", + projection: "EPSG:3857", + attributions: '数据来源:天地图', }), }); @@ -132,7 +137,9 @@ const BaseLayers: React.FC = () => { return map ? [map] : []; }, [data?.maps, map]); const sharedSources = useMemo(() => createBaseLayerSources(), []); - const layerSetsRef = useRef(new WeakMap>()); + const layerSetsRef = useRef( + new WeakMap>(), + ); const [isShow, setShow] = useState(false); const [isExpanded, setExpanded] = useState(false); const [activeId, setActiveId] = useState(INITIAL_LAYER); @@ -244,7 +251,7 @@ const BaseLayers: React.FC = () => {
{ "object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300", { "ring-1 ring-blue-300": activeId === item.id, - } + }, )} /> {item.name} -- 2.54.0 From 6114e767356aedc523c97a715e12a4801682d72b Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 16:21:30 +0800 Subject: [PATCH 252/281] fix(sensor-placement): stabilize engineering drawing export Render the OpenLayers basemap at the final A3 map-box resolution with pixelRatio 1, carry provider attribution into the drawing, and keep the linework/basemap export path covered by regression tests. --- .../SchemeDrawingDialog.tsx | 49 ++++++++++++++++--- .../engineeringDrawing.test.ts | 6 +++ .../engineeringDrawing.ts | 16 ++++++ .../olmap/core/Controls/BaseLayers.test.ts | 15 ++++++ .../olmap/core/Controls/BaseLayers.tsx | 45 +++++++++++++---- 5 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx index e88ce3a..34779ff 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeDrawingDialog.tsx @@ -33,6 +33,7 @@ import { lineStringFromFlatCoordinates, } from "@components/olmap/core/tileFeatureIndex"; import { + A3_LANDSCAPE_HEIGHT, A3_LANDSCAPE_WIDTH, canvasToPngBlob, getPaddedDrawingExtent, @@ -55,7 +56,10 @@ interface SchemeDrawingDialogProps { } const DRAWING_MAP_RENDER_TIMEOUT_MS = 10_000; -const DRAWING_MAP_SIZE: [number, number] = [1600, 981]; +const DRAWING_MAP_SIZE: [number, number] = [ + A3_LANDSCAPE_WIDTH - 300, + A3_LANDSCAPE_HEIGHT - 650, +]; type DrawingMode = "linework" | "basemap"; @@ -106,17 +110,26 @@ export const fitMapToFullNetwork = ( interface DrawingMapSession { map: OlMap; extent: [number, number, number, number]; + attribution: string | null; dispose: () => void; } -const cloneVisibleBaseLayers = (mainMap: OlMap): TileLayer[] => { +const cloneVisibleBaseLayers = ( + mainMap: OlMap, +): { layers: TileLayer[]; attribution: string | null } => { const cloned: TileLayer[] = []; + const attributions = new Set(); const collect = (layer: unknown, parentVisible = true) => { const candidate = layer as { getVisible?: () => boolean; + get?: (key: string) => unknown; getLayers?: () => { getArray: () => unknown[] }; }; if (!parentVisible || candidate.getVisible?.() === false) return; + const attribution = candidate.get?.("exportAttribution"); + if (typeof attribution === "string" && attribution) { + attributions.add(attribution); + } if (layer instanceof LayerGroup) { candidate.getLayers?.().getArray().forEach((child) => collect(child)); return; @@ -133,7 +146,10 @@ const cloneVisibleBaseLayers = (mainMap: OlMap): TileLayer[] => { ); }; mainMap.getLayers().getArray().forEach((layer) => collect(layer)); - return cloned; + return { + layers: cloned, + attribution: attributions.size ? [...attributions].join(" · ") : null, + }; }; const createDrawingMapSession = ( @@ -180,12 +196,14 @@ const createDrawingMapSession = ( }); pipeLayer.set("value", "pipes"); pipeLayer.setExtent(networkExtent); - const layers = + const basemap = mode === "basemap" - ? [...cloneVisibleBaseLayers(mainMap), pipeLayer] - : [pipeLayer]; + ? cloneVisibleBaseLayers(mainMap) + : { layers: [], attribution: null }; + const layers = [...basemap.layers, pipeLayer]; drawingMap = new OlMap({ target, + pixelRatio: 1, view: new View({ projection: mainMap.getView().getProjection(), }), @@ -200,6 +218,7 @@ const createDrawingMapSession = ( return { map: drawingMap, extent: networkExtent, + attribution: basemap.attribution, dispose: () => { if (disposed) return; disposed = true; @@ -378,6 +397,7 @@ const SchemeDrawingDialog: React.FC = ({ ); const [drawingMode, setDrawingMode] = useState("linework"); const [mapCanvas, setMapCanvas] = useState(null); + const [mapAttribution, setMapAttribution] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -393,6 +413,7 @@ const SchemeDrawingDialog: React.FC = ({ setError(null); setNetworkData(null); setMapCanvas(null); + setMapAttribution(null); if (!map) { setError("主地图尚未初始化,请稍后重试"); setLoading(false); @@ -424,6 +445,9 @@ const SchemeDrawingDialog: React.FC = ({ if (!cancelled) { setNetworkData(data); setMapCanvas(captured); + setMapAttribution( + drawingMode === "basemap" ? drawingSession.attribution : null, + ); } }) .catch((reason) => { @@ -455,6 +479,7 @@ const SchemeDrawingDialog: React.FC = ({ network, networkData, mapCanvas, + mapAttribution, dirty, width: 1600, }) @@ -478,7 +503,16 @@ const SchemeDrawingDialog: React.FC = ({ cancelled = true; if (currentUrl) URL.revokeObjectURL(currentUrl); }; - }, [dirty, mapCanvas, network, networkData, open, rows, scheme]); + }, [ + dirty, + mapAttribution, + mapCanvas, + network, + networkData, + open, + rows, + scheme, + ]); const createFullResolutionBlob = async () => { if (!networkData) throw new Error("管网数据尚未加载"); @@ -488,6 +522,7 @@ const SchemeDrawingDialog: React.FC = ({ network, networkData, mapCanvas, + mapAttribution, dirty, width: A3_LANDSCAPE_WIDTH, }); diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts index a0243b1..c25a3da 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.test.ts @@ -320,6 +320,7 @@ describe("engineering drawing", () => { extent: [0, 0, 1000, 1000], }, mapCanvas, + mapAttribution: "© Mapbox © OpenStreetMap", dirty: false, width: 1600, }); @@ -331,5 +332,10 @@ describe("engineering drawing", () => { expect.any(Number), expect.any(Number), ); + expect(context.fillText).toHaveBeenCalledWith( + "底图来源:© Mapbox © OpenStreetMap", + expect.any(Number), + expect.any(Number), + ); }); }); diff --git a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts index d1d6fcd..3950f22 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts +++ b/src/components/olmap/MonitoringPlaceOptimization/engineeringDrawing.ts @@ -13,6 +13,7 @@ interface DrawingOptions { network: string; networkData: NetworkDrawingData; mapCanvas?: HTMLCanvasElement | null; + mapAttribution?: string | null; dirty: boolean; width?: number; } @@ -123,6 +124,7 @@ export const renderEngineeringDrawing = async ({ network, networkData, mapCanvas, + mapAttribution, dirty, width = A3_LANDSCAPE_WIDTH, }: DrawingOptions): Promise => { @@ -261,6 +263,20 @@ export const renderEngineeringDrawing = async ({ context.strokeStyle = "#111827"; context.lineWidth = px(4); context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height); + if (mapCanvas && mapAttribution) { + context.fillStyle = "#334155"; + drawText( + context, + `底图来源:${mapAttribution}`, + mapBox.x + mapBox.width - px(16), + mapBox.y + mapBox.height - px(18), + px(18), + 500, + "right", + "rgba(255,255,255,0.96)", + px(6), + ); + } for (let index = 0; index <= 4; index += 1) { const ratio = index / 4; diff --git a/src/components/olmap/core/Controls/BaseLayers.test.ts b/src/components/olmap/core/Controls/BaseLayers.test.ts index 5008031..5db2f20 100644 --- a/src/components/olmap/core/Controls/BaseLayers.test.ts +++ b/src/components/olmap/core/Controls/BaseLayers.test.ts @@ -16,24 +16,38 @@ jest.mock("ol/layer/Tile.js", () => ({ __esModule: true, default: class MockTileLayer { private readonly source: unknown; + private readonly properties = new Map(); constructor(options: any) { this.source = options.source; } getSource() { return this.source; } + set(key: string, value: unknown) { + this.properties.set(key, value); + } + get(key: string) { + return this.properties.get(key); + } }, })); jest.mock("ol/layer/Group", () => ({ __esModule: true, default: class MockGroup { private readonly layers: unknown[]; + private readonly properties = new Map(); constructor(options: any) { this.layers = options.layers; } getLayers() { return { getArray: () => this.layers }; } + set(key: string, value: unknown) { + this.properties.set(key, value); + } + get(key: string) { + return this.properties.get(key); + } }, })); @@ -70,6 +84,7 @@ describe("base layer resources", () => { expect(getLeafSources(entry.layer)).toEqual( getLeafSources(compare[index].layer), ); + expect(entry.layer.get("exportAttribution")).toBe(entry.attribution); }); }); }); diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index ebb001b..f74f140 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -18,16 +18,42 @@ import { markMapResourcePersistent } from "../mapLifecycle"; const INITIAL_LAYER = "mapbox-light"; const BASE_LAYER_METADATA = [ - { id: "mapbox-light", name: "默认地图", img: mapboxLight.src }, - { id: "mapbox-satellite", name: "卫星地图", img: mapboxSatellite.src }, + { + id: "mapbox-light", + name: "默认地图", + img: mapboxLight.src, + attribution: "© Mapbox © OpenStreetMap", + }, + { + id: "mapbox-satellite", + name: "卫星地图", + img: mapboxSatellite.src, + attribution: "© Mapbox", + }, { id: "mapbox-satellite-streets", name: "卫星街道地图", img: mapboxSatelliteStreet.src, + attribution: "© Mapbox © OpenStreetMap", + }, + { + id: "mapbox-streets", + name: "街道地图", + img: mapboxStreets.src, + attribution: "© Mapbox © OpenStreetMap", + }, + { + id: "tianditu-vector", + name: "天地图矢量", + img: mapboxOutdoors.src, + attribution: "© 天地图", + }, + { + id: "tianditu-image", + name: "天地图影像", + img: mapboxSatellite.src, + attribution: "© 天地图", }, - { id: "mapbox-streets", name: "街道地图", img: mapboxStreets.src }, - { id: "tianditu-vector", name: "天地图矢量", img: mapboxOutdoors.src }, - { id: "tianditu-image", name: "天地图影像", img: mapboxSatellite.src }, ] as const; const createTileSource = (url: string, attributions: string) => @@ -123,10 +149,11 @@ export const createBaseLayerEntries = (sources: BaseLayerSources) => { ], }), }, - ].map((entry) => ({ - ...entry, - layer: markMapResourcePersistent(entry.layer), - })); + ].map((entry) => { + const layer = markMapResourcePersistent(entry.layer); + layer.set("exportAttribution", entry.attribution); + return { ...entry, layer }; + }); }; const BaseLayers: React.FC = () => { -- 2.54.0 From f355ddd0021b00cda5e3569512af74d44d1beae4 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 16:38:06 +0800 Subject: [PATCH 253/281] fix(sensor-placement): smooth result editor transitions --- .../MonitoringPlaceOptimizationPanel.test.tsx | 123 ++++++++++++++++++ .../MonitoringPlaceOptimizationPanel.tsx | 70 ++++++++-- 2 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.test.tsx diff --git a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.test.tsx b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.test.tsx new file mode 100644 index 0000000..5b0206a --- /dev/null +++ b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.test.tsx @@ -0,0 +1,123 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import MonitoringPlaceOptimizationPanel, { + getTabIndicatorSx, + getTabIndicatorTransform, +} from "./MonitoringPlaceOptimizationPanel"; +import { getSensorPlacementScheme } from "./schemeApi"; +import type { SensorPlacementScheme } from "./types"; + +const mockSchemeEditorRender = jest.fn(); + +jest.mock("@refinedev/core", () => ({ + useNotification: () => ({ open: jest.fn() }), +})); + +jest.mock("./OptimizationParameters", () => ({ + __esModule: true, + default: () =>
optimization parameters
, + createOptimizationParametersState: () => ({}), +})); + +jest.mock("./SchemeQuery", () => ({ + __esModule: true, + default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => ( + + ), + createMonitoringSchemeQueryState: () => ({}), +})); + +jest.mock("./SchemeEditor", () => ({ + __esModule: true, + default: ({ + scheme, + active, + }: { + scheme: SensorPlacementScheme; + active: boolean; + }) => { + mockSchemeEditorRender(active); + return
{scheme.scheme_name}
; + }, +})); + +jest.mock("./schemeApi", () => ({ + getSensorPlacementScheme: jest.fn(), +})); + +jest.mock("@components/olmap/common/PanelEmptyState", () => ({ + __esModule: true, + default: () =>
empty state
, +})); + +const mockGetSensorPlacementScheme = jest.mocked(getSensorPlacementScheme); + +const scheme: SensorPlacementScheme = { + id: 7, + scheme_name: "测试方案", + sensor_number: 1, + min_diameter: 100, + username: "operator", + create_time: "2026-07-30T08:00:00+08:00", + sensor_location: ["J-1"], + sensor_points: [], + can_edit: true, +}; + +describe("MonitoringPlaceOptimizationPanel", () => { + beforeEach(() => { + mockSchemeEditorRender.mockClear(); + }); + + it("applies equal-width positioning to the rendered tab indicator", () => { + expect(getTabIndicatorTransform(0)).toBe("translateX(0%)"); + expect(getTabIndicatorTransform(1)).toBe("translateX(100%)"); + expect(getTabIndicatorTransform(2)).toBe("translateX(200%)"); + expect(getTabIndicatorSx(1)).toMatchObject({ + left: "0 !important", + width: "33.333333% !important", + transform: "translateX(100%)", + }); + + const { container } = render(); + const indicator = container.querySelector( + ".MuiTabs-indicator", + ); + + expect(indicator).not.toBeNull(); + expect(indicator).toHaveStyle({ transform: "translateX(0%)" }); + + fireEvent.click(screen.getByRole("tab", { name: /方案查询/ })); + expect(indicator).toHaveStyle({ transform: "translateX(200%)" }); + }); + + it("prepares a queried scheme before switching to the result editor", async () => { + let resolveScheme: (value: SensorPlacementScheme) => void = () => {}; + mockGetSensorPlacementScheme.mockReturnValue( + new Promise((resolve) => { + resolveScheme = resolve; + }), + ); + + render(); + + const queryTab = screen.getByRole("tab", { name: /方案查询/ }); + const editorTab = screen.getByRole("tab", { name: /结果编辑/ }); + fireEvent.click(queryTab); + fireEvent.click(screen.getByRole("button", { name: "打开测试方案" })); + + expect(queryTab).toHaveAttribute("aria-selected", "true"); + expect(editorTab).toHaveAttribute("aria-selected", "false"); + + await act(async () => { + resolveScheme(scheme); + await Promise.resolve(); + }); + + expect(editorTab).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("scheme-editor")).toBeVisible(); + expect(mockSchemeEditorRender.mock.calls.map(([active]) => active)).toEqual([ + false, + true, + ]); + }); +}); diff --git a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx index 772a720..c77de01 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Box, Drawer, @@ -52,6 +52,39 @@ const TabPanel: React.FC = ({ children, value, index }) => { ); }; +const PANEL_MOTION_DURATION_MS = 280; +const PANEL_MOTION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)"; + +export const getTabIndicatorTransform = (tabIndex: number) => + `translateX(${tabIndex * 100}%)`; + +export const getTabIndicatorSx = (tabIndex: number) => ({ + backgroundColor: "#257DD4", + left: "0 !important", + width: "33.333333% !important", + transform: getTabIndicatorTransform(tabIndex), + transition: `transform ${PANEL_MOTION_DURATION_MS}ms ${PANEL_MOTION_EASING}`, + "@media (prefers-reduced-motion: reduce)": { + transition: "none", + }, +}); + +interface PreparedSchemeEditorProps + extends React.ComponentProps { + onReady?: () => void; +} + +const PreparedSchemeEditor: React.FC = ({ + onReady, + ...props +}) => { + useEffect(() => { + onReady?.(); + }, [onReady]); + + return ; +}; + interface MonitoringPlaceOptimizationPanelProps { open?: boolean; onToggle?: () => void; @@ -65,6 +98,9 @@ const MonitoringPlaceOptimizationPanel: React.FC< const [activeScheme, setActiveScheme] = useState(null); const [loadingScheme, setLoadingScheme] = useState(false); + const [pendingOpenSchemeId, setPendingOpenSchemeId] = useState( + null, + ); const { open: notify } = useNotification(); // 持久化方案查询结果 @@ -91,11 +127,18 @@ const MonitoringPlaceOptimizationPanel: React.FC< const drawerWidth = currentTab === 1 ? 820 : 520; + const handleSchemeEditorReady = useCallback(() => { + setCurrentTab(1); + setPendingOpenSchemeId(null); + }, []); + const handleOpenScheme = async (schemeId: number) => { setLoadingScheme(true); - setCurrentTab(1); try { - setActiveScheme(await getSensorPlacementScheme(NETWORK_NAME, schemeId)); + const scheme = await getSensorPlacementScheme(NETWORK_NAME, schemeId); + setActiveScheme(scheme); + setLoadingScheme(false); + setPendingOpenSchemeId(scheme.id); } catch (error) { const detail = (error as { response?: { data?: { detail?: string } } }).response?.data @@ -105,8 +148,6 @@ const MonitoringPlaceOptimizationPanel: React.FC< message: "方案加载失败", description: detail, }); - setCurrentTab(2); - } finally { setLoadingScheme(false); } }; @@ -173,11 +214,17 @@ const MonitoringPlaceOptimizationPanel: React.FC< "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", backdropFilter: "blur(8px)", opacity: 0.95, - transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out", + transition: + `width ${PANEL_MOTION_DURATION_MS}ms ${PANEL_MOTION_EASING}, ` + + "transform 300ms ease-in-out, opacity 300ms ease-in-out", + willChange: "width, transform, opacity", border: "none", "&:hover": { opacity: 1, }, + "@media (prefers-reduced-motion: reduce)": { + transition: "none", + }, }, }} > @@ -219,9 +266,7 @@ const MonitoringPlaceOptimizationPanel: React.FC< "& .Mui-selected": { color: "#257DD4", }, - "& .MuiTabs-indicator": { - backgroundColor: "#257DD4", - }, + "& .MuiTabs-indicator": getTabIndicatorSx(currentTab), }} > ) : activeScheme ? ( - ) : ( Date: Thu, 30 Jul 2026 16:45:10 +0800 Subject: [PATCH 254/281] feat(frontend): enforce RBAC and refine burst analysis --- src/app/RefineContext.tsx | 449 ++++++++++-------- src/components/admin/SystemAdminPanel.tsx | 104 ++-- src/components/audit/AuditLogPanel.tsx | 63 +-- src/components/auth/RoutePermissionGuard.tsx | 45 ++ .../BurstSimulation/AnalysisReport.test.tsx | 62 ++- .../olmap/BurstSimulation/AnalysisReport.tsx | 34 +- .../olmap/BurstSimulation/ValveIsolation.tsx | 20 +- src/components/olmap/BurstSimulation/types.ts | 1 + .../BurstSimulation/valveIsolationScope.ts | 13 + .../olmap/core/Controls/Timeline.tsx | 61 +-- .../olmap/core/Controls/Toolbar.tsx | 21 +- src/contexts/ProjectContext.tsx | 136 +++--- src/lib/permissions.test.ts | 34 ++ src/lib/permissions.ts | 91 ++++ src/store/accessStore.ts | 28 ++ 15 files changed, 772 insertions(+), 390 deletions(-) create mode 100644 src/components/auth/RoutePermissionGuard.tsx create mode 100644 src/lib/permissions.test.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/store/accessStore.ts diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 87773c8..35c5622 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -1,32 +1,38 @@ "use client"; -import { Refine, type AuthProvider } from "@refinedev/core"; -import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { - RefineSnackbarProvider, -} from "@refinedev/mui"; + Refine, + type AccessControlProvider, + type AuthProvider, +} from "@refinedev/core"; +import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; +import { RefineSnackbarProvider } from "@refinedev/mui"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import routerProvider from "@refinedev/nextjs-router"; import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; import { ProjectProvider } from "@/contexts/ProjectContext"; +import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard"; import { useAuthStore } from "@/store/authStore"; +import { useAccessStore } from "@/store/accessStore"; +import { useProjectStore } from "@/store/projectStore"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { config } from "@config/config"; import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; import { LiaNetworkWiredSolid } from "react-icons/lia"; -import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; +import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb"; import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; -import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; +import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md"; import { - ManageAccounts as ManageAccountsIcon, FactCheck as FactCheckIcon, + ManageAccounts as ManageAccountsIcon, MyLocation as MyLocationIcon, Search as SearchIcon, } from "@mui/icons-material"; @@ -36,16 +42,12 @@ type RefineContextProps = { }; export const RefineContext = ( - props: React.PropsWithChildren -) => { - return ( - - - - - - ); -}; + props: React.PropsWithChildren, +) => ( + + + +); type AppProps = { defaultMode?: string; @@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren) => { const { data, status } = useSession(); const to = usePathname(); const setAccessToken = useAuthStore((state) => state.setAccessToken); - const [isMetadataAdmin, setIsMetadataAdmin] = useState(false); + const currentProjectId = useProjectStore((state) => state.currentProjectId); + const permissions = useAccessStore((state) => state.permissions); + const setAccessContext = useAccessStore((state) => state.setContext); + const setAccessLoading = useAccessStore((state) => state.setLoading); + const resetAccess = useAccessStore((state) => state.reset); + const can = (permission: string) => permissions.includes(permission); useEffect(() => { - setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null); + setAccessToken( + typeof data?.accessToken === "string" ? data.accessToken : null, + ); }, [data?.accessToken, setAccessToken]); useEffect(() => { if (status !== "authenticated") { - setIsMetadataAdmin(false); + resetAccess(); return; } let cancelled = false; - apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { - projectHeaderMode: "omit", + setAccessLoading(true); + apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, { + projectHeaderMode: currentProjectId ? "include" : "omit", skipAuthRedirect: true, }) .then(async (response) => { if (cancelled) return; if (!response.ok) { - setIsMetadataAdmin(false); + resetAccess(); return; } - const payload = await response.json(); - setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin")); + setAccessContext(await response.json()); }) .catch(() => { - if (!cancelled) setIsMetadataAdmin(false); + if (!cancelled) resetAccess(); }); return () => { cancelled = true; }; - }, [status]); + }, [ + currentProjectId, + resetAccess, + setAccessContext, + setAccessLoading, + status, + ]); + + useEffect(() => { + if (status !== "authenticated" || !data?.user?.id) return; + const auditKey = `tjwater-login-audit:${data.user.id}`; + if (sessionStorage.getItem(auditKey)) return; + + apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event: "login" }), + projectHeaderMode: "omit", + skipAuthRedirect: true, + }) + .then((response) => { + if (response.ok) sessionStorage.setItem(auditKey, "1"); + }) + .catch(() => undefined); + }, [data?.user?.id, status]); if (status === "loading") { return loading...; @@ -100,200 +133,230 @@ const App = (props: React.PropsWithChildren) => { callbackUrl: to ? to.toString() : "/", redirect: true, }); - - return { - success: true, - }; + return { success: true }; }, logout: async () => { - signOut({ - redirect: true, - callbackUrl: "/login", - }); - - return { - success: true, - }; + try { + await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event: "logout" }), + projectHeaderMode: "omit", + skipAuthRedirect: true, + }); + } catch { + // Logout must still complete when audit storage is unavailable. + } + if (data?.user?.id) { + sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); + } + signOut({ redirect: true, callbackUrl: "/login" }); + return { success: true }; }, onError: async (error) => { if (error.response?.status === 401) { - return { - logout: true, - }; + return { logout: true }; } - - return { - error, - }; - }, - check: async () => { - if (status === "unauthenticated") { - return { - authenticated: false, - redirectTo: "/login", - }; - } - - return { - authenticated: true, - }; - }, - getPermissions: async () => { - return null; + return { error }; }, + check: async () => + status === "unauthenticated" + ? { authenticated: false, redirectTo: "/login" } + : { authenticated: true }, + getPermissions: async () => permissions, getIdentity: async () => { - if (data?.user) { - const { user } = data; - return { - id: user.id, - username: user.username, - name: user.name, - avatar: user.image, - }; - } - - return null; + if (!data?.user) return null; + return { + id: data.user.id, + username: data.user.username, + name: data.user.name, + avatar: data.user.image, + }; }, }; - const defaultMode = props?.defaultMode; + const accessControlProvider: AccessControlProvider = { + can: async ({ resource }) => { + const requiredPermission = resource + ? resourcePermissions[resource] + : undefined; + return { + can: !requiredPermission || permissions.includes(requiredPermission), + reason: requiredPermission + ? `需要权限:${requiredPermission}` + : undefined, + }; + }, + }; + + const resources = [ + ...(can(permissionCodes.simulationView) + ? [ + { + name: "管网在线模拟", + list: "/network-simulation", + meta: { + icon: , + label: "管网在线模拟", + }, + }, + ] + : []), + ...(can(permissionCodes.scadaClean) + ? [ + { + name: "SCADA 数据清洗", + list: "/scada-data-cleaning", + meta: { + icon: , + label: "SCADA 数据清洗", + }, + }, + ] + : []), + ...(can(permissionCodes.optimizationRun) + ? [ + { + name: "监测点优化布置", + list: "/monitoring-place-optimization", + meta: { + icon: , + label: "监测点优化布置", + }, + }, + ] + : []), + ...(can(permissionCodes.riskRun) + ? [ + { + name: "健康风险分析", + list: "/health-risk-analysis", + meta: { + icon: , + label: "健康风险分析", + }, + }, + ] + : []), + ...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun) + ? [ + { + name: "Hydraulic Simulation", + meta: { label: "事件模拟" }, + }, + ] + : []), + ...(can(permissionCodes.burstRun) + ? [ + { + name: "爆管模拟", + list: "/hydraulic-simulation/burst-simulation", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "爆管模拟", + }, + }, + { + name: "爆管侦测", + list: "/hydraulic-simulation/burst-detection", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "爆管侦测", + }, + }, + { + name: "爆管定位", + list: "/hydraulic-simulation/burst-location", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "爆管定位", + }, + }, + { + name: "DMA 漏损识别", + list: "/hydraulic-simulation/dma-leak-detection", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "DMA 漏损识别", + }, + }, + ] + : []), + ...(can(permissionCodes.simulationRun) + ? [ + { + name: "水质模拟", + list: "/hydraulic-simulation/contaminant-simulation", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "水质模拟", + }, + }, + { + name: "管道冲洗", + list: "/hydraulic-simulation/flushing-analysis", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "管道冲洗", + }, + }, + ] + : []), + ...(can(permissionCodes.environmentManage) + ? [ + { + name: "系统管理", + list: "/system-admin", + meta: { + icon: , + label: "系统管理", + }, + }, + ] + : []), + ...(can(permissionCodes.auditView) + ? [ + { + name: "审计日志", + list: "/audit-logs", + meta: { + icon: , + label: "审计日志", + }, + }, + ] + : []), + ]; return ( - <> + - + , - label: "管网在线模拟", - }, - }, - { - name: "SCADA 数据清洗", - list: "/scada-data-cleaning", - meta: { - icon: , - label: "SCADA 数据清洗", - }, - }, - { - name: "监测点优化布置", - list: "/monitoring-place-optimization", - meta: { - icon: , - label: "监测点优化布置", - }, - }, - { - name: "健康风险分析", - list: "/health-risk-analysis", - meta: { - icon: , - label: "健康风险分析", - }, - }, - { - name: "Hydraulic Simulation", - meta: { - // icon: , - label: "事件模拟", - }, - }, - { - name: "爆管模拟", - list: "/hydraulic-simulation/burst-simulation", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "爆管模拟", - }, - }, - { - name: "爆管侦测", - list: "/hydraulic-simulation/burst-detection", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "爆管侦测", - }, - }, - { - name: "爆管定位", - list: "/hydraulic-simulation/burst-location", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "爆管定位", - }, - }, - { - name: "DMA 漏损识别", - list: "/hydraulic-simulation/dma-leak-detection", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "DMA 漏损识别", - }, - }, - { - name: "水质模拟", - list: "/hydraulic-simulation/contaminant-simulation", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "水质模拟", - }, - }, - { - name: "管道冲洗", - list: "/hydraulic-simulation/flushing-analysis", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "管道冲洗", - }, - }, - ...(isMetadataAdmin - ? [ - { - name: "系统管理", - list: "/system-admin", - meta: { - icon: , - label: "系统管理", - }, - }, - { - name: "审计日志", - list: "/audit-logs", - meta: { - icon: , - label: "审计日志", - }, - }, - ] - : []), - ]} + accessControlProvider={accessControlProvider} + resources={resources} options={{ syncWithLocation: true, warnWhenUnsavedChanges: true, }} > - {props.children} + {props.children} - + ); }; diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index b1f1ad2..dc81947 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -106,24 +106,30 @@ type DatabaseHealth = { const businessRoleOptions = [ { value: "admin", label: "系统管理员" }, - { value: "operator", label: "运行人员" }, { value: "user", label: "普通用户" }, - { value: "viewer", label: "只读用户" }, ]; const projectRoleLabels: Record = { - owner: "项目负责人", - admin: "项目管理员", member: "项目成员", viewer: "只读成员", }; const projectRoleOptions = [ - { value: "admin", label: projectRoleLabels.admin }, { value: "member", label: projectRoleLabels.member }, { value: "viewer", label: projectRoleLabels.viewer }, ]; +const projectRolePermissionSummary = [ + { + role: "项目成员", + permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析", + }, + { + role: "只读成员", + permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读", + }, +]; + const projectStatusOptions = [ { value: "active", label: "启用" }, { value: "inactive", label: "停用" }, @@ -1340,7 +1346,7 @@ export const SystemAdminPanel = () => { { } /> + + + 固定项目权限 + + + {projectRolePermissionSummary.map((item) => ( + + ))} + + {!hasProjectId && ( 当前未选择管理项目。 @@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => { )} {members.map((member) => { const isSelf = member.user_id === currentAdmin?.id; - const isOwner = member.project_role === "owner"; return ( @@ -1486,54 +1510,38 @@ export const SystemAdminPanel = () => { {member.email} - {isOwner ? ( - - + + - - ) : ( - - - + - updateMemberRole(member, e.target.value) - } - renderValue={(value) => - getProjectRoleLabel(String(value)) - } - > - {projectRoleOptions.map((role) => ( - - {role.label} - - ))} - - - - - )} + {projectRoleOptions.map((role) => ( + + {role.label} + + ))} + + + + diff --git a/src/components/audit/AuditLogPanel.tsx b/src/components/audit/AuditLogPanel.tsx index d82c5e7..ad89516 100644 --- a/src/components/audit/AuditLogPanel.tsx +++ b/src/components/audit/AuditLogPanel.tsx @@ -49,6 +49,8 @@ import { } from "@mui/icons-material"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; type AuditLog = { id: string; @@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = { end_time: "", }; +const AUDIT_LOGS_PATH = "/api/v1/audit/logs"; + const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ { value: "all", label: "全部状态" }, { value: "success", label: "成功 2xx" }, @@ -338,8 +342,14 @@ const DetailSection = ({ ); export const AuditLogPanel = () => { - const [adminChecked, setAdminChecked] = useState(false); - const [isAuthorized, setIsAuthorized] = useState(false); + const accessLoading = useAccessStore((state) => state.loading); + const permissions = useAccessStore((state) => state.permissions); + const isSystemAdmin = useAccessStore( + (state) => state.context?.is_system_admin === true, + ); + const canViewAudit = permissions.includes(permissionCodes.auditView); + const adminChecked = !accessLoading; + const isAuthorized = adminChecked && canViewAudit && isSystemAdmin; const [logs, setLogs] = useState([]); const [users, setUsers] = useState([]); const [projects, setProjects] = useState([]); @@ -392,8 +402,8 @@ export const AuditLogPanel = () => { const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage); const countParams = buildCountParams(appliedFilters); const [logsResponse, countResponse] = await Promise.all([ - apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`), - apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`), + apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`), + apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`), ]); if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse)); if (!countResponse.ok) throw new Error(await readErrorText(countResponse)); @@ -402,7 +412,7 @@ export const AuditLogPanel = () => { setTotalCount(Number(countPayload.count ?? 0)); } else { const params = buildServerParams(appliedFilters, 0, 1000); - const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`); if (!response.ok) throw new Error(await readErrorText(response)); const payload = (await response.json()) as AuditLog[]; setLogs(payload); @@ -417,42 +427,9 @@ export const AuditLogPanel = () => { }, [appliedFilters, page, rowsPerPage]); useEffect(() => { - let cancelled = false; - - const checkAdmin = async () => { - try { - const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { - projectHeaderMode: "omit", - skipAuthRedirect: true, - }); - if (cancelled) return; - if (!response.ok) { - setIsAuthorized(false); - setAdminChecked(true); - return; - } - const payload = await response.json(); - setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin")); - setAdminChecked(true); - } catch (err) { - if (!cancelled) { - setIsAuthorized(false); - setAdminChecked(true); - setError(String(err)); - } - } - }; - - void checkAdmin(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (!adminChecked || !isAuthorized) return; + if (!isAuthorized) return; void loadOptions(); - }, [adminChecked, isAuthorized, loadOptions]); + }, [isAuthorized, loadOptions]); useEffect(() => { if (!adminChecked || !isAuthorized) return; @@ -476,7 +453,7 @@ export const AuditLogPanel = () => { setError(null); try { const params = buildServerParams(appliedFilters, 0, 1000); - const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`); if (!response.ok) throw new Error(await readErrorText(response)); const payload = ((await response.json()) as AuditLog[]).filter((log) => matchesStatusFilter(log, appliedFilters.status), @@ -544,7 +521,7 @@ export const AuditLogPanel = () => { 审计日志 - 管理员审计查询 + 全局审计查询 @@ -552,7 +529,7 @@ export const AuditLogPanel = () => { } - label="管理员权限已验证" + label="系统管理员权限已验证" sx={{ alignSelf: { xs: "flex-start", md: "center" } }} /> )} diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx new file mode 100644 index 0000000..0c455c0 --- /dev/null +++ b/src/components/auth/RoutePermissionGuard.tsx @@ -0,0 +1,45 @@ +"use client"; + +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +import { permissionForPath } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; + +export const RoutePermissionGuard = ({ + children, +}: { + children: ReactNode; +}) => { + const pathname = usePathname(); + const permissions = useAccessStore((state) => state.permissions); + const loading = useAccessStore((state) => state.loading); + const requiredPermission = permissionForPath(pathname); + + if (requiredPermission && loading) { + return ( + + + + ); + } + + if (requiredPermission && !permissions.includes(requiredPermission)) { + return ( + + }> + + 无权访问此功能 + + 当前项目角色缺少权限:{requiredPermission} + + + + + ); + } + + return children; +}; diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx index 4032017..e6e244f 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx @@ -11,7 +11,10 @@ import AnalysisReport, { matchesValveAnalysis, } from "./AnalysisReport"; import { SchemeRecord, ValveIsolationResult } from "./types"; -import { isAllowedAccidentPipe } from "./valveIsolationScope"; +import { + isAllowedAccidentPipe, + normalizeValveIsolationResult, +} from "./valveIsolationScope"; jest.mock("@/utils/mapQueryService", () => ({ queryFeaturesByIds: jest.fn(), @@ -139,12 +142,69 @@ describe("AnalysisReport", () => { ); }); + it("shows only the affected count for a non-isolatable result", async () => { + const legacyAffectedNodes = Array.from( + { length: 100 }, + (_, index) => `legacy-node-${index}`, + ); + const nonIsolatableResult = { + ...valveResult, + affected_nodes: legacyAffectedNodes, + affected_node_count: 85747, + must_close_valves: [], + isolatable: false, + }; + + render( + , + ); + + const preview = within( + screen.getByTestId("burst-analysis-report-preview"), + ); + expect(preview.getByText("85747 个")).toBeInTheDocument(); + expect( + preview.getByText("不可隔离,未生成受影响节点清单。"), + ).toBeInTheDocument(); + expect(preview.queryByText("legacy-node-0")).not.toBeInTheDocument(); + expect(preview.queryByText("legacy-node-99")).not.toBeInTheDocument(); + await waitFor(() => + expect(preview.getByText("315 mm")).toBeInTheDocument(), + ); + }); + it("limits scheme-bound valve analysis to the scheme accident pipes", () => { expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true); expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false); expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true); }); + it("normalizes legacy valve results without changing isolatable lists", () => { + expect( + normalizeValveIsolationResult({ + ...valveResult, + affected_nodes: ["J-1", "J-2", "J-3"], + must_close_valves: [], + isolatable: false, + }), + ).toMatchObject({ + affected_nodes: [], + affected_node_count: 3, + isolatable: false, + }); + + expect(normalizeValveIsolationResult(valveResult)).toMatchObject({ + affected_nodes: ["J-1", "J-2"], + affected_node_count: 2, + isolatable: true, + }); + }); + it("prints only after assigning the report print state", async () => { const originalTitle = document.title; const print = jest diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.tsx index 2049b0f..3624c53 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.tsx @@ -35,6 +35,7 @@ import { type PipeDiameterMap, } from "./schemePipeDiameters"; import { SchemeRecord, ValveIsolationResult } from "./types"; +import { getAffectedNodeCount } from "./valveIsolationScope"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface AnalysisReportProps { @@ -212,6 +213,18 @@ const ReportDocument: React.FC = ({ ? valveResult : null; const duration = scheme.schemeDetail?.modify_total_duration; + const valveDetailRows: Array<[string, string[] | undefined]> = + matchedValveResult + ? [ + ["已分析事故管段", matchedValveResult.accident_elements], + ["必关阀门", matchedValveResult.must_close_valves], + ["可选阀门", matchedValveResult.optional_valves], + ["不可用阀门", disabledValves], + ] + : []; + if (matchedValveResult?.isolatable) { + valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]); + } return ( = ({ {[ ["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"], ["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`], - ["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0} 个`], + ["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`], ].map(([label, value]) => ( = ({ ))} - {[ - ["已分析事故管段", matchedValveResult.accident_elements], - ["必关阀门", matchedValveResult.must_close_valves], - ["可选阀门", matchedValveResult.optional_valves], - ["不可用阀门", disabledValves], - ["受影响节点", matchedValveResult.affected_nodes], - ].map(([label, values]) => ( - + {valveDetailRows.map(([label, values]) => ( + - {label as string} + {label} - + ))} + {!matchedValveResult.isolatable && ( + + 不可隔离,未生成受影响节点清单。 + + )} ) : ( diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index 19ff8db..761f513 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -52,7 +52,11 @@ import { import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; -import { isAllowedAccidentPipe } from "./valveIsolationScope"; +import { + getAffectedNodeCount, + isAllowedAccidentPipe, + normalizeValveIsolationResult, +} from "./valveIsolationScope"; interface ValveIsolationProps { initialPipeIds?: string[]; @@ -363,7 +367,7 @@ const ValveIsolation: React.FC = ({ if (disabled.length > 0) { params.disabled_valves = disabled; } - const response = await api.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/valve-isolation-analysis`, { params, @@ -372,7 +376,7 @@ const ValveIsolation: React.FC = ({ }, }, ); - setResult(response.data); + setResult(normalizeValveIsolationResult(response.data)); if (!isExpandSearch) { setActiveStep(1); } else { @@ -711,7 +715,7 @@ const ValveIsolation: React.FC = ({ {[ { label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" }, { label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" }, - { label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" }, + { label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" }, ].map((item, index) => ( = ({ )} + {!result.isolatable && ( + + 不可隔离,未生成受影响节点清单。 + + )} + {/* 受影响节点 */} - {result.affected_nodes && result.affected_nodes.length > 0 && ( + {result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && ( diff --git a/src/components/olmap/BurstSimulation/types.ts b/src/components/olmap/BurstSimulation/types.ts index 8db31a0..302945d 100644 --- a/src/components/olmap/BurstSimulation/types.ts +++ b/src/components/olmap/BurstSimulation/types.ts @@ -31,6 +31,7 @@ export interface SchemaItem { export interface ValveIsolationResult { accident_elements: string[]; affected_nodes: string[]; + affected_node_count?: number; must_close_valves: string[]; optional_valves: string[]; isolatable: boolean; diff --git a/src/components/olmap/BurstSimulation/valveIsolationScope.ts b/src/components/olmap/BurstSimulation/valveIsolationScope.ts index 9c8c14c..d8cdac1 100644 --- a/src/components/olmap/BurstSimulation/valveIsolationScope.ts +++ b/src/components/olmap/BurstSimulation/valveIsolationScope.ts @@ -1,4 +1,17 @@ +import type { ValveIsolationResult } from "./types"; + export const isAllowedAccidentPipe = ( pipeId: string, allowedPipeIds: string[] | undefined, ) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId); + +export const getAffectedNodeCount = (result: ValveIsolationResult) => + result.affected_node_count ?? result.affected_nodes?.length ?? 0; + +export const normalizeValveIsolationResult = ( + result: ValveIsolationResult, +): ValveIsolationResult => ({ + ...result, + affected_nodes: result.isolatable ? result.affected_nodes ?? [] : [], + affected_node_count: getAffectedNodeCount(result), +}); diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index dc7869b..e0f423f 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -29,6 +29,8 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi"; import { useData } from "../MapComponent"; import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; import { useMap } from "../MapComponent"; import { formatTimelineTime, @@ -81,6 +83,9 @@ const Timeline: React.FC = ({ schemeType = "burst_analysis", }) => { const data = useData(); + const canRunSimulation = useAccessStore((state) => + state.permissions.includes(permissionCodes.simulationRun), + ); const fallbackSelectedDateRef = useRef(new Date()); const hasTimelineState = data && @@ -926,35 +931,35 @@ const Timeline: React.FC = ({ - - {/* 强制计算时间段 */} - - 计算时间段 - - + {canRunSimulation && ( + + + 计算时间段 + + - {/* 功能按钮 */} - - - - + + + + + )} {/* 当前时间显示 */} = ({ const map = useMap(); const data = useData(); const project = useProject(); + const canEditNetwork = useAccessStore((state) => + state.permissions.includes(permissionCodes.webgisEdit), + ); const { open } = useNotification(); const [activeTools, setActiveTools] = useState([]); const [highlightFeatures, setHighlightFeatures] = useState([]); @@ -119,6 +124,15 @@ const Toolbar: React.FC = ({ } }, [enableCompare, isCompareMode, toggleCompareMode]); + useEffect(() => { + if (!canEditNetwork) { + setShowDrawPanel(false); + setActiveTools((previous) => + previous.filter((tool) => tool !== "draw"), + ); + } + }, [canEditNetwork]); + // Chat tool action → direct featureInfos override (bypasses OL Feature lookup) const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState< [string, string][] | null @@ -697,7 +711,7 @@ const Toolbar: React.FC = ({ buildFeatureProperties( selectedFeature, computedProperties, - selectedValveId + canEditNetwork && selectedValveId ? { value: valveStatus, loading: isValveStatusLoading, @@ -705,7 +719,7 @@ const Toolbar: React.FC = ({ onSave: handleValveStatusSave, } : undefined, - selectedValveId + canEditNetwork && selectedValveId ? { value: valveProperties.setting, vType: selectedValveType, @@ -719,6 +733,7 @@ const Toolbar: React.FC = ({ [ selectedFeature, computedProperties, + canEditNetwork, selectedValveId, valveStatus, valveProperties, @@ -755,7 +770,7 @@ const Toolbar: React.FC = ({ onClick={() => handleToolClick("history")} /> )} - {!hiddenButtons?.includes("draw") && ( + {canEditNetwork && !hiddenButtons?.includes("draw") && ( } name="标记绘制" diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 8e768f9..447c202 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -1,9 +1,26 @@ "use client"; -import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; + import { useSession } from "next-auth/react"; -import { config, NETWORK_NAME, setMapWorkspace, setNetworkName, setMapExtent } from "@/config/config"; +import { usePathname, useRouter } from "next/navigation"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + import { ProjectSelector } from "@/components/project/ProjectSelector"; +import { + config, + NETWORK_NAME, + setMapExtent, + setMapWorkspace, + setNetworkName, +} from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; import { useProjectStore } from "@/store/projectStore"; interface ProjectContextType { @@ -21,6 +38,11 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { status } = useSession(); + const pathname = usePathname(); + const router = useRouter(); + const canManageEnvironment = useAccessStore((state) => + state.permissions.includes(permissionCodes.environmentManage), + ); const [isConfigured, setIsConfigured] = useState(false); const setCurrentProjectId = useProjectStore( (state) => state.setCurrentProjectId, @@ -31,76 +53,69 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ extent: config.MAP_EXTENT, }); - const applyConfig = useCallback(async ( - projectId: string, - ws: string, - net: string, - extent: number[], - ) => { - const resolvedProjectId = projectId || net || ws; - setMapWorkspace(ws); - setNetworkName(net); - setMapExtent(extent); - localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(",")); - // Reset extent cache - localStorage.removeItem(`${ws}_map_view`); - setCurrentProject({ workspace: ws, networkName: net, extent: extent }); - setCurrentProjectId(resolvedProjectId); + const applyConfig = useCallback( + async ( + projectId: string, + workspace: string, + networkName: string, + extent: number[], + ) => { + const resolvedProjectId = projectId || networkName || workspace; + setMapWorkspace(workspace); + setNetworkName(networkName); + setMapExtent(extent); + localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace); + localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName); + localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(",")); + localStorage.removeItem(`${workspace}_map_view`); + setCurrentProject({ workspace, networkName, extent }); + setCurrentProjectId(resolvedProjectId); + setIsConfigured(true); - // Save to localStorage - localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, ws); - localStorage.setItem(NETWORK_NAME_STORAGE_KEY, net); - - setIsConfigured(true); - - try { - // Open project backend (simulation model) - const openResponse = await apiFetch( - `${config.BACKEND_URL}/api/v1/projects/open?network=${net}`, - { - method: "POST", - }, - ); - if (!openResponse.ok) { - throw new Error(`Failed to open project: HTTP ${openResponse.status}`); - } - - // Fetch project metadata - const infoResponse = await apiFetch( - `${config.BACKEND_URL}/api/v1/project-info?network=${net}`, - ); - if (!infoResponse.ok) { - console.warn( - `Failed to fetch project info: HTTP ${infoResponse.status}`, + try { + const openResponse = await apiFetch( + `${config.BACKEND_URL}/api/v1/projects/open?network=${networkName}`, + { method: "POST" }, ); - } else { - const data = await infoResponse.json(); + if (!openResponse.ok) { + throw new Error(`Failed to open project: HTTP ${openResponse.status}`); + } - // Update workspace if different - if (data?.gs_workspace && data.gs_workspace !== ws) { + const infoResponse = await apiFetch( + `${config.BACKEND_URL}/api/v1/project-info?network=${networkName}`, + ); + if (!infoResponse.ok) { + console.warn( + `Failed to fetch project info: HTTP ${infoResponse.status}`, + ); + return; + } + + const data = await infoResponse.json(); + if (data?.gs_workspace && data.gs_workspace !== workspace) { setMapWorkspace(data.gs_workspace); localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace); - setCurrentProject((prev) => ({ - ...prev, + setCurrentProject((previous) => ({ + ...previous, workspace: data.gs_workspace, })); } - // Update extent if available const bbox = Array.isArray(data?.map_extent?.bbox) ? data.map_extent.bbox.map((value: number) => Number(value)) : null; - if (bbox && bbox.length === 4) { + if (bbox?.length === 4) { setMapExtent(bbox); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(",")); - localStorage.removeItem(`${ws}_map_view`); - setCurrentProject((prev) => ({ ...prev, extent: bbox })); + localStorage.removeItem(`${workspace}_map_view`); + setCurrentProject((previous) => ({ ...previous, extent: bbox })); } + } catch (error) { + console.error("Failed to setup project:", error); } - } catch (error) { - console.error("Failed to setup project:", error); - } - }, [setCurrentProjectId]); + }, + [setCurrentProjectId], + ); useEffect(() => { // Check localStorage @@ -120,11 +135,16 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ } }, [applyConfig]); - // Only show selector if authenticated and not configured - if (status === "authenticated" && !isConfigured) { + const isGlobalManagementRoute = + pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs"); + + if (status === "authenticated" && !isConfigured && !isGlobalManagementRoute) { return ( router.push("/system-admin") : undefined + } onSelect={(projectId, ws, net, extent) => applyConfig(projectId, ws, net, extent) } diff --git a/src/lib/permissions.test.ts b/src/lib/permissions.test.ts new file mode 100644 index 0000000..a5b102b --- /dev/null +++ b/src/lib/permissions.test.ts @@ -0,0 +1,34 @@ +import { + permissionCodes, + permissionForPath, + resourcePermissions, +} from "./permissions"; + +describe("permission mappings", () => { + it("maps protected routes to backend permission codes", () => { + expect(permissionForPath("/system-admin")).toBe( + permissionCodes.environmentManage, + ); + expect(permissionForPath("/scada-data-cleaning/devices")).toBe( + permissionCodes.scadaClean, + ); + expect(permissionForPath("/monitoring-place-optimization")).toBe( + permissionCodes.optimizationRun, + ); + expect( + permissionForPath("/hydraulic-simulation/burst-location"), + ).toBe(permissionCodes.burstRun); + }); + + it("leaves public or project-selection routes without a feature permission", () => { + expect(permissionForPath("/login")).toBeUndefined(); + expect(permissionForPath("/")).toBeUndefined(); + }); + + it("uses the same permission codes for resources and routes", () => { + expect(resourcePermissions["系统管理"]).toBe( + permissionCodes.environmentManage, + ); + expect(resourcePermissions["审计日志"]).toBe(permissionCodes.auditView); + }); +}); diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..885c148 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,91 @@ +export const permissionCodes = { + webgisView: "webgis.view", + webgisEdit: "webgis.edit", + scadaView: "scada.view", + scadaClean: "scada.clean", + simulationView: "simulation.view", + simulationRun: "simulation.run", + burstView: "burst.view", + burstRun: "burst.run", + riskView: "risk.view", + riskRun: "risk.run", + optimizationView: "optimization.view", + optimizationRun: "optimization.run", + modelImport: "model.import", + auditView: "audit.view", + environmentManage: "environment.manage", + membershipManage: "membership.manage", +} as const; + +export type PermissionCode = + (typeof permissionCodes)[keyof typeof permissionCodes]; + +export type AccessContext = { + user_id: string; + username: string; + system_role: string; + is_system_admin: boolean; + project_id?: string | null; + project_role?: string | null; + permissions: string[]; +}; + +export const resourcePermissions: Record = { + "管网在线模拟": permissionCodes.simulationView, + "SCADA 数据清洗": permissionCodes.scadaClean, + "监测点优化布置": permissionCodes.optimizationRun, + "健康风险分析": permissionCodes.riskRun, + "爆管模拟": permissionCodes.burstRun, + "爆管侦测": permissionCodes.burstRun, + "爆管定位": permissionCodes.burstRun, + "DMA 漏损识别": permissionCodes.burstRun, + "水质模拟": permissionCodes.simulationRun, + "管道冲洗": permissionCodes.simulationRun, + "系统管理": permissionCodes.environmentManage, + "审计日志": permissionCodes.auditView, +}; + +export const pathPermissions: Array<{ + prefix: string; + permission: PermissionCode; +}> = [ + { prefix: "/system-admin", permission: permissionCodes.environmentManage }, + { prefix: "/audit-logs", permission: permissionCodes.auditView }, + { prefix: "/scada-data-cleaning", permission: permissionCodes.scadaClean }, + { + prefix: "/monitoring-place-optimization", + permission: permissionCodes.optimizationRun, + }, + { prefix: "/health-risk-analysis", permission: permissionCodes.riskRun }, + { + prefix: "/hydraulic-simulation/burst-simulation", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/burst-detection", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/burst-location", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/dma-leak-detection", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/contaminant-simulation", + permission: permissionCodes.simulationRun, + }, + { + prefix: "/hydraulic-simulation/flushing-analysis", + permission: permissionCodes.simulationRun, + }, + { + prefix: "/network-simulation", + permission: permissionCodes.simulationView, + }, +]; + +export const permissionForPath = (pathname: string) => + pathPermissions.find(({ prefix }) => pathname.startsWith(prefix))?.permission; diff --git a/src/store/accessStore.ts b/src/store/accessStore.ts new file mode 100644 index 0000000..b7212a7 --- /dev/null +++ b/src/store/accessStore.ts @@ -0,0 +1,28 @@ +import { create } from "zustand"; + +import type { AccessContext } from "@/lib/permissions"; + +type AccessState = { + context: AccessContext | null; + permissions: string[]; + loading: boolean; + setLoading: (loading: boolean) => void; + setContext: (context: AccessContext) => void; + reset: () => void; +}; + +export const useAccessStore = create((set) => ({ + context: null, + permissions: [], + loading: true, + setLoading: (loading) => set({ loading }), + setContext: (context) => + set({ + context, + permissions: Array.isArray(context.permissions) + ? context.permissions + : [], + loading: false, + }), + reset: () => set({ context: null, permissions: [], loading: false }), +})); -- 2.54.0 From 38d246eb7b7f4aed3f8348f754a4b79e910e3820 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 18:15:36 +0800 Subject: [PATCH 255/281] =?UTF-8?q?fix(dma):=20=E6=94=BE=E5=AE=BD=E6=80=BB?= =?UTF-8?q?=E6=BC=8F=E6=8D=9F=E6=B5=81=E9=87=8F=E4=B8=8B=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../olmap/DMALeakDetection/AnalysisParameters.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index c79b8a6..33c373c 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -82,7 +82,7 @@ const AnalysisParameters: React.FC = ({ const parsedQSum = Number(qSumInput); const qSumIsValid = - qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 360; + qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0; const isValid = useMemo(() => { if (!schemeName.trim() || !startTime || !endTime) return false; @@ -94,7 +94,7 @@ const AnalysisParameters: React.FC = ({ open?.({ type: "error", message: "请完善参数并确认时间范围合法", - description: !qSumIsValid ? `总漏损流量需不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined, + description: !qSumIsValid ? `总漏损流量不能小于 0 ${FLOW_DISPLAY_UNIT}` : undefined, }); return; } @@ -235,11 +235,11 @@ const AnalysisParameters: React.FC = ({ setFormField("qSum", value); } }} - inputProps={{ min: 360, step: 10 }} + inputProps={{ min: 0, step: 10 }} error={qSumInput.trim() !== "" && !qSumIsValid} helperText={ qSumInput.trim() !== "" && !qSumIsValid - ? `需不小于 360 ${FLOW_DISPLAY_UNIT}` + ? `不能小于 0 ${FLOW_DISPLAY_UNIT}` : " " } /> -- 2.54.0 From b57e58ff87d6f28fb3b7b5fde8bb6ee7c1749ecf Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 19:07:17 +0800 Subject: [PATCH 256/281] =?UTF-8?q?fix(chat):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8C=BA=E6=93=8D=E4=BD=9C=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentComposer.test.tsx | 61 ++++++++++++++++ src/components/chat/AgentComposer.tsx | 83 ++++++++++------------ 2 files changed, 98 insertions(+), 46 deletions(-) create mode 100644 src/components/chat/AgentComposer.test.tsx diff --git a/src/components/chat/AgentComposer.test.tsx b/src/components/chat/AgentComposer.test.tsx new file mode 100644 index 0000000..32c0004 --- /dev/null +++ b/src/components/chat/AgentComposer.test.tsx @@ -0,0 +1,61 @@ +/* eslint-disable @next/next/no-img-element */ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; + +import { AgentComposer } from "./AgentComposer"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes) => ( + {props.alt + ), +})); + +jest.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + motion: { + div: ({ + children, + animate: _animate, + exit: _exit, + initial: _initial, + transition: _transition, + ...props + }: React.HTMLAttributes & Record) => ( +
{children}
+ ), + }, +})); + +describe("AgentComposer", () => { + it("places voice input immediately before send without an attachment action", () => { + render( + + + , + ); + + const voiceButton = screen.getByRole("button", { name: "语音输入" }); + const sendButton = screen.getByRole("button", { name: "发送" }); + + expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument(); + expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument(); + expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument(); + expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true); + }); +}); diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index d40a5c1..a6e0b16 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -23,7 +23,6 @@ import StopRounded from "@mui/icons-material/StopRounded"; import MicRounded from "@mui/icons-material/MicRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; -import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; import BoltRounded from "@mui/icons-material/BoltRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; @@ -125,16 +124,10 @@ export const AgentComposer = React.forwardRef - TJWater Agent + 管网分析快捷指令 @@ -230,41 +223,6 @@ export const AgentComposer = React.forwardRef - - - - {isSttSupported ? ( - isListening ? ( - - - - - - ) : ( - - - - ) - ) : null}