Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bcf28f7e | ||
|
|
ab45c8da8e |
+6
-9
@@ -1,10 +1,7 @@
|
|||||||
node_modules
|
**/node_modules/
|
||||||
.next
|
**/dist
|
||||||
out
|
|
||||||
build
|
|
||||||
.git
|
.git
|
||||||
.env*.local
|
npm-debug.log
|
||||||
README.md
|
.coverage
|
||||||
docker-compose.yml
|
.coverage.*
|
||||||
Dockerfile
|
.env
|
||||||
.dockerignore
|
|
||||||
|
|||||||
@@ -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_COPILOT_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"
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
+14
-122
@@ -1,129 +1,21 @@
|
|||||||
name: Build Push and Deploy
|
name: Frontend CI/CD v2
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-image:
|
build-test-publish-and-deploy:
|
||||||
runs-on: ubuntu
|
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||||
permissions:
|
with:
|
||||||
contents: read
|
image_name: gitea.waternetwork.cn/orgtjwater/tjwaterfrontend_refine
|
||||||
defaults:
|
dockerfile: Dockerfile
|
||||||
run:
|
build_context: .
|
||||||
shell: sh
|
deploy_service: frontend
|
||||||
|
deploy_host: 192.168.1.114
|
||||||
steps:
|
secrets:
|
||||||
- name: Checkout code
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
env:
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
SERVER_URL: ${{ github.server_url }}
|
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||||
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
|
|
||||||
|
|
||||||
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 --force --detach FETCH_HEAD
|
|
||||||
git clean -ffdx
|
|
||||||
|
|
||||||
- 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}"
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
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 }}" \
|
|
||||||
.
|
|
||||||
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 }}" \
|
|
||||||
-d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
|
|
||||||
|
|
||||||
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."
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+4
-16
@@ -1,4 +1,4 @@
|
|||||||
FROM refinedev/node:22 AS base
|
FROM refinedev/node:18 AS base
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
@@ -15,18 +15,6 @@ RUN \
|
|||||||
|
|
||||||
FROM base AS builder
|
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
|
|
||||||
|
|
||||||
COPY --from=deps /app/refine/node_modules ./node_modules
|
COPY --from=deps /app/refine/node_modules ./node_modules
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -35,7 +23,7 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
COPY --from=builder /app/refine/public ./public
|
COPY --from=builder /app/refine/public ./public
|
||||||
|
|
||||||
@@ -49,7 +37,7 @@ USER refine
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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}
|
|
||||||
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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
|
||||||
|
|
||||||
const config = [...nextCoreWebVitals];
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,22 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "refine.ams3.cdn.digitaloceanspaces.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
turbopack: {
|
|
||||||
rules: {
|
|
||||||
"*.svg": {
|
|
||||||
loaders: ["@svgr/webpack"],
|
|
||||||
as: "*.js",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
|
|||||||
Generated
+884
-3051
File diff suppressed because it is too large
Load Diff
+11
-23
@@ -9,7 +9,7 @@
|
|||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
||||||
"build": "refine build",
|
"build": "refine build",
|
||||||
"start": "refine start",
|
"start": "refine start",
|
||||||
"lint": "eslint .",
|
"lint": "next lint",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
@@ -24,10 +24,12 @@
|
|||||||
"@mui/x-charts": "^7.29.1",
|
"@mui/x-charts": "^7.29.1",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
"@mui/x-date-pickers": "^8.12.0",
|
"@mui/x-date-pickers": "^8.12.0",
|
||||||
"@refinedev/core": "^5.0.12",
|
"@refinedev/cli": "^2.16.50",
|
||||||
|
"@refinedev/core": "^5.0.8",
|
||||||
|
"@refinedev/devtools": "^2.0.3",
|
||||||
"@refinedev/kbar": "^2.0.1",
|
"@refinedev/kbar": "^2.0.1",
|
||||||
"@refinedev/mui": "^8.0.2",
|
"@refinedev/mui": "^8.0.0",
|
||||||
"@refinedev/nextjs-router": "^7.0.5",
|
"@refinedev/nextjs-router": "^7.0.4",
|
||||||
"@refinedev/react-hook-form": "^5.0.4",
|
"@refinedev/react-hook-form": "^5.0.4",
|
||||||
"@refinedev/simple-rest": "^6.0.1",
|
"@refinedev/simple-rest": "^6.0.1",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
@@ -37,32 +39,19 @@
|
|||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^15.5.11",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.1.0",
|
||||||
"react-draggable": "^4.5.0",
|
"react-draggable": "^4.5.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-window": "^1.8.10",
|
"react-window": "^1.8.10",
|
||||||
"remark-gfm": "^4.0.1",
|
"tailwindcss": "^4.1.13"
|
||||||
"tailwindcss": "^4.1.13",
|
|
||||||
"zustand": "^5.0.11"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"fast-xml-parser": "5.5.9"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -73,10 +62,9 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^15.0.3",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
||||||
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
|
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
|
||||||
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
|
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
|
||||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
import StyleLegend from "@app/OlMap/Controls/StyleLegend";
|
||||||
import {
|
import {
|
||||||
RAINBOW_COLORS,
|
RAINBOW_COLORS,
|
||||||
RISK_BREAKS,
|
RISK_BREAKS,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,16 +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" />
|
|
||||||
<BurstPipeAnalysisPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,16 +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" />
|
|
||||||
<WaterQualityPanel />
|
|
||||||
</MapComponent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import React, { Suspense } from "react";
|
import React, { Suspense } from "react";
|
||||||
|
import { RefineContext } from "../_refine_context";
|
||||||
|
|
||||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||||
import { Header } from "@components/header";
|
import { Header } from "@components/header";
|
||||||
@@ -32,6 +33,7 @@ export default async function MainLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<RefineContext defaultMode={defaultMode}>
|
||||||
<ThemedLayout
|
<ThemedLayout
|
||||||
Header={Header}
|
Header={Header}
|
||||||
Title={Title}
|
Title={Title}
|
||||||
@@ -46,6 +48,7 @@ export default async function MainLayout({
|
|||||||
{children}
|
{children}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ThemedLayout>
|
</ThemedLayout>
|
||||||
|
</RefineContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||||
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
|
import MapToolbar from "@app/OlMap/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@app/OlMap/Controls/Timeline";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
||||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
|
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||||
|
import BurstPipeAnalysisPanel from "@/components/olmap/BurstPipeAnalysis/BurstPipeAnalysisPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
|
<MapComponent>
|
||||||
|
<MapToolbar queryType="scheme" />
|
||||||
|
<BurstPipeAnalysisPanel />
|
||||||
|
</MapComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
||||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
|||||||
+4
-7
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
import TileLayer from "ol/layer/Tile.js";
|
import TileLayer from "ol/layer/Tile.js";
|
||||||
import XYZ from "ol/source/XYZ.js";
|
import XYZ from "ol/source/XYZ.js";
|
||||||
@@ -137,7 +136,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
}
|
}
|
||||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
||||||
});
|
});
|
||||||
}, [map, activeId]);
|
}, [map]);
|
||||||
|
|
||||||
const changeMapLayers = (id: string) => {
|
const changeMapLayers = (id: string) => {
|
||||||
if (map) {
|
if (map) {
|
||||||
@@ -180,7 +179,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-17 bottom-11 z-20">
|
<div className="absolute right-17 bottom-8 z-1300">
|
||||||
<div
|
<div
|
||||||
className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black"
|
className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black"
|
||||||
onMouseEnter={handleEnter}
|
onMouseEnter={handleEnter}
|
||||||
@@ -188,7 +187,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="w-20 h-20 p-1">
|
<div className="w-20 h-20 p-1">
|
||||||
<button onClick={() => handleQuickSwitch()}>
|
<button onClick={() => handleQuickSwitch()}>
|
||||||
<Image
|
<img
|
||||||
width={240}
|
width={240}
|
||||||
height={100}
|
height={100}
|
||||||
src={
|
src={
|
||||||
@@ -201,7 +200,6 @@ const BaseLayers: React.FC = () => {
|
|||||||
? baseLayers[1].name
|
? baseLayers[1].name
|
||||||
: baseLayers[0].name
|
: baseLayers[0].name
|
||||||
}
|
}
|
||||||
sizes="72px"
|
|
||||||
className="object-cover object-left w-18 h-18 rounded-xl"
|
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">
|
<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">
|
||||||
@@ -229,12 +227,11 @@ const BaseLayers: React.FC = () => {
|
|||||||
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)}
|
onClick={() => handleMapLayers(item.id)}
|
||||||
>
|
>
|
||||||
<Image
|
<img
|
||||||
width={240}
|
width={240}
|
||||||
height={100}
|
height={100}
|
||||||
src={item.img}
|
src={item.img}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
sizes="64px"
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300",
|
"object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300",
|
||||||
{
|
{
|
||||||
+31
-44
@@ -31,15 +31,12 @@ import { useMap } from "../MapComponent";
|
|||||||
const DrawPanel: React.FC = () => {
|
const DrawPanel: React.FC = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [activeTool, setActiveTool] = useState<string>("pan");
|
const [activeTool, setActiveTool] = useState<string>("pan");
|
||||||
const drawLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
const [drawLayer, setDrawLayer] = useState<VectorLayer<VectorSource> | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]);
|
const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]);
|
||||||
const [history, setHistory] = useState<{
|
const [historyStack, setHistoryStack] = useState<Feature<Geometry>[][]>([]);
|
||||||
stack: Feature<Geometry>[][];
|
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||||
index: number;
|
|
||||||
}>({
|
|
||||||
stack: [[]],
|
|
||||||
index: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const drawInteractionRef = useRef<Draw | null>(null);
|
const drawInteractionRef = useRef<Draw | null>(null);
|
||||||
|
|
||||||
@@ -77,14 +74,13 @@ const DrawPanel: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.addLayer(drawVectorLayer);
|
map.addLayer(drawVectorLayer);
|
||||||
drawLayerRef.current = drawVectorLayer;
|
setDrawLayer(drawVectorLayer);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (drawInteractionRef.current && map) {
|
if (drawInteractionRef.current && map) {
|
||||||
map.removeInteraction(drawInteractionRef.current);
|
map.removeInteraction(drawInteractionRef.current);
|
||||||
drawInteractionRef.current = null;
|
drawInteractionRef.current = null;
|
||||||
}
|
}
|
||||||
drawLayerRef.current = null;
|
|
||||||
map.removeLayer(drawVectorLayer);
|
map.removeLayer(drawVectorLayer);
|
||||||
};
|
};
|
||||||
}, [map, drawInteractionRef]);
|
}, [map, drawInteractionRef]);
|
||||||
@@ -92,16 +88,14 @@ const DrawPanel: React.FC = () => {
|
|||||||
// 保存到历史记录
|
// 保存到历史记录
|
||||||
const saveToHistory = useCallback(
|
const saveToHistory = useCallback(
|
||||||
(features: Feature<Geometry>[]) => {
|
(features: Feature<Geometry>[]) => {
|
||||||
setHistory((prev) => {
|
setHistoryStack((prevStack) => {
|
||||||
const newStack = prev.stack.slice(0, prev.index + 1);
|
const newHistory = prevStack.slice(0, historyIndex + 1);
|
||||||
newStack.push([...features]);
|
newHistory.push([...features]);
|
||||||
return {
|
setHistoryIndex(newHistory.length - 1);
|
||||||
stack: newStack,
|
return newHistory;
|
||||||
index: newStack.length - 1,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[]
|
[historyIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 添加绘图交互
|
// 添加绘图交互
|
||||||
@@ -109,7 +103,6 @@ const DrawPanel: React.FC = () => {
|
|||||||
type: GeometryType,
|
type: GeometryType,
|
||||||
geometryFunction?: GeometryFunction
|
geometryFunction?: GeometryFunction
|
||||||
) => {
|
) => {
|
||||||
const drawLayer = drawLayerRef.current;
|
|
||||||
if (!drawLayer) return;
|
if (!drawLayer) return;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
@@ -160,13 +153,7 @@ const DrawPanel: React.FC = () => {
|
|||||||
// 绘图完成事件
|
// 绘图完成事件
|
||||||
draw.on("drawend", (event: DrawEvent) => {
|
draw.on("drawend", (event: DrawEvent) => {
|
||||||
const feature = event.feature;
|
const feature = event.feature;
|
||||||
const currentFeatures = [...source.getFeatures()];
|
const currentFeatures = [...drawnFeatures, feature];
|
||||||
|
|
||||||
// Fallback in case feature has not been synced to source yet.
|
|
||||||
if (!currentFeatures.includes(feature)) {
|
|
||||||
currentFeatures.push(feature);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDrawnFeatures(currentFeatures);
|
setDrawnFeatures(currentFeatures);
|
||||||
saveToHistory(currentFeatures);
|
saveToHistory(currentFeatures);
|
||||||
});
|
});
|
||||||
@@ -257,35 +244,28 @@ const DrawPanel: React.FC = () => {
|
|||||||
|
|
||||||
// 撤销功能
|
// 撤销功能
|
||||||
const handleUndo = () => {
|
const handleUndo = () => {
|
||||||
if (history.index > 0) {
|
if (historyIndex > 0) {
|
||||||
const newIndex = history.index - 1;
|
const newIndex = historyIndex - 1;
|
||||||
const previousFeatures = history.stack[newIndex];
|
const previousFeatures = historyStack[newIndex];
|
||||||
updateDrawLayer(previousFeatures);
|
updateDrawLayer(previousFeatures);
|
||||||
setDrawnFeatures(previousFeatures);
|
setDrawnFeatures(previousFeatures);
|
||||||
setHistory((prev) => ({
|
setHistoryIndex(newIndex);
|
||||||
...prev,
|
|
||||||
index: newIndex,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重做功能
|
// 重做功能
|
||||||
const handleRedo = () => {
|
const handleRedo = () => {
|
||||||
if (history.index < history.stack.length - 1) {
|
if (historyIndex < historyStack.length - 1) {
|
||||||
const newIndex = history.index + 1;
|
const newIndex = historyIndex + 1;
|
||||||
const nextFeatures = history.stack[newIndex];
|
const nextFeatures = historyStack[newIndex];
|
||||||
updateDrawLayer(nextFeatures);
|
updateDrawLayer(nextFeatures);
|
||||||
setDrawnFeatures(nextFeatures);
|
setDrawnFeatures(nextFeatures);
|
||||||
setHistory((prev) => ({
|
setHistoryIndex(newIndex);
|
||||||
...prev,
|
|
||||||
index: newIndex,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除所有绘制的要素
|
// 删除所有绘制的要素
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
const drawLayer = drawLayerRef.current;
|
|
||||||
if (!drawLayer) return;
|
if (!drawLayer) return;
|
||||||
|
|
||||||
const source = drawLayer.getSource();
|
const source = drawLayer.getSource();
|
||||||
@@ -302,7 +282,6 @@ const DrawPanel: React.FC = () => {
|
|||||||
|
|
||||||
// 更新绘图图层
|
// 更新绘图图层
|
||||||
const updateDrawLayer = (features: Feature<Geometry>[]) => {
|
const updateDrawLayer = (features: Feature<Geometry>[]) => {
|
||||||
const drawLayer = drawLayerRef.current;
|
|
||||||
if (!drawLayer) return;
|
if (!drawLayer) return;
|
||||||
|
|
||||||
const source = drawLayer.getSource();
|
const source = drawLayer.getSource();
|
||||||
@@ -312,9 +291,17 @@ const DrawPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 初始化历史记录
|
||||||
|
useEffect(() => {
|
||||||
|
// 初始化空的历史记录
|
||||||
|
if (historyStack.length === 0) {
|
||||||
|
saveToHistory([]);
|
||||||
|
}
|
||||||
|
}, [historyStack.length, saveToHistory]);
|
||||||
|
|
||||||
// 判断按钮是否应该禁用
|
// 判断按钮是否应该禁用
|
||||||
const isUndoDisabled = history.index <= 0;
|
const isUndoDisabled = historyIndex <= 0;
|
||||||
const isRedoDisabled = history.index >= history.stack.length - 1;
|
const isRedoDisabled = historyIndex >= historyStack.length - 1;
|
||||||
const isDeleteDisabled = drawnFeatures.length === 0;
|
const isDeleteDisabled = drawnFeatures.length === 0;
|
||||||
const isSaveDisabled = drawnFeatures.length === 0;
|
const isSaveDisabled = drawnFeatures.length === 0;
|
||||||
|
|
||||||
+12
-55
@@ -34,7 +34,6 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|||||||
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||||
import config from "@/config/config";
|
import config from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -59,10 +58,6 @@ export interface SCADADataPanelProps {
|
|||||||
defaultTab?: "chart" | "table";
|
defaultTab?: "chart" | "table";
|
||||||
/** Y 轴数值的小数位数 */
|
/** Y 轴数值的小数位数 */
|
||||||
fractionDigits?: number;
|
fractionDigits?: number;
|
||||||
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
|
|
||||||
start_time?: string;
|
|
||||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
|
||||||
end_time?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelTab = "chart" | "table";
|
type PanelTab = "chart" | "table";
|
||||||
@@ -108,10 +103,10 @@ const fetchFromBackend = async (
|
|||||||
if (type === "none") {
|
if (type === "none") {
|
||||||
// 查询清洗值和监测值
|
// 查询清洗值和监测值
|
||||||
const [cleanedRes, rawRes] = await Promise.all([
|
const [cleanedRes, rawRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetch(cleanedDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(rawDataUrl)
|
fetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -131,13 +126,13 @@ const fetchFromBackend = async (
|
|||||||
} else if (type === "scheme") {
|
} else if (type === "scheme") {
|
||||||
// 查询策略模拟值、清洗值和监测值
|
// 查询策略模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([
|
const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetch(cleanedDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(rawDataUrl)
|
fetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(schemeSimulationDataUrl)
|
fetch(schemeSimulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -183,13 +178,13 @@ const fetchFromBackend = async (
|
|||||||
} else {
|
} else {
|
||||||
// realtime: 查询模拟值、清洗值和监测值
|
// realtime: 查询模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetch(cleanedDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(rawDataUrl)
|
fetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(simulationDataUrl)
|
fetch(simulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -396,12 +391,10 @@ const emptyStateMessages: Record<
|
|||||||
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||||
featureInfos,
|
featureInfos,
|
||||||
type = "none",
|
type = "none",
|
||||||
scheme_type = "burst_analysis",
|
scheme_type = "burst_Analysis",
|
||||||
scheme_name,
|
scheme_name,
|
||||||
defaultTab = "chart",
|
defaultTab = "chart",
|
||||||
fractionDigits = 2,
|
fractionDigits = 2,
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
}) => {
|
}) => {
|
||||||
// 从 featureInfos 中提取设备 ID 列表
|
// 从 featureInfos 中提取设备 ID 列表
|
||||||
const deviceIds = useMemo(
|
const deviceIds = useMemo(
|
||||||
@@ -409,24 +402,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
[featureInfos]
|
[featureInfos]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [from, setFrom] = useState<Dayjs>(() => {
|
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
||||||
if (start_time) {
|
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
||||||
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 [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
@@ -440,30 +417,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
}, [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 normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||||
|
|
||||||
const hasDevices = deviceIds.length > 0;
|
const hasDevices = deviceIds.length > 0;
|
||||||
const hasData = timeSeries.length > 0;
|
const hasData = timeSeries.length > 0;
|
||||||
const featureInfosKey = useMemo(
|
|
||||||
() => JSON.stringify(featureInfos),
|
|
||||||
[featureInfos]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dataset = useMemo(
|
const dataset = useMemo(
|
||||||
() => buildDataset(timeSeries, deviceIds, fractionDigits),
|
() => buildDataset(timeSeries, deviceIds, fractionDigits),
|
||||||
@@ -510,7 +467,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
}
|
}
|
||||||
}, [featureInfosKey, handleFetch, hasDevices]);
|
}, [JSON.stringify(featureInfos)]);
|
||||||
|
|
||||||
// 当设备数量变化时,调整数据源选择
|
// 当设备数量变化时,调整数据源选择
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useData, useMap } from "../MapComponent";
|
||||||
|
import { Checkbox, FormControlLabel } from "@mui/material";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorTileLayer from "ol/layer/VectorTile";
|
||||||
|
import { DeckLayer } from "@utils/layers";
|
||||||
|
|
||||||
|
// 定义统一的图层项接口
|
||||||
|
interface LayerItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
type: "ol" | "deck";
|
||||||
|
layerRef: any; // OpenLayers Layer 实例或 deck.gl layer 对象
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayerControl: React.FC = () => {
|
||||||
|
const data = useData();
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return <LayerControlContent data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LayerControlContent: React.FC<{
|
||||||
|
data: NonNullable<ReturnType<typeof useData>>;
|
||||||
|
}> = ({ data }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const {
|
||||||
|
deckLayer,
|
||||||
|
isContourLayerAvailable,
|
||||||
|
isWaterflowLayerAvailable,
|
||||||
|
setShowWaterflowLayer,
|
||||||
|
setShowContourLayer,
|
||||||
|
} = data;
|
||||||
|
const [layerItems, setLayerItems] = useState<LayerItem[]>([]);
|
||||||
|
|
||||||
|
const layerOrder = [
|
||||||
|
"junctions",
|
||||||
|
"reservoirs",
|
||||||
|
"tanks",
|
||||||
|
"pipes",
|
||||||
|
"pumps",
|
||||||
|
"valves",
|
||||||
|
"scada",
|
||||||
|
"waterflowLayer",
|
||||||
|
"junctionContourLayer",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 更新图层列表
|
||||||
|
const updateLayers = useCallback(() => {
|
||||||
|
if (!map || !data) return;
|
||||||
|
|
||||||
|
const items: LayerItem[] = [];
|
||||||
|
|
||||||
|
// 1. 获取 OpenLayers 图层
|
||||||
|
const mapLayers = map.getLayers().getArray();
|
||||||
|
mapLayers.forEach((layer) => {
|
||||||
|
// 筛选特定类型的 OpenLayers 图层
|
||||||
|
if (
|
||||||
|
layer instanceof WebGLVectorTileLayer ||
|
||||||
|
layer instanceof VectorTileLayer ||
|
||||||
|
layer instanceof VectorLayer
|
||||||
|
) {
|
||||||
|
const value = layer.get("value");
|
||||||
|
const name = layer.get("name");
|
||||||
|
// 只有设置了 value (作为 ID) 的图层才会被纳入控制
|
||||||
|
if (value) {
|
||||||
|
items.push({
|
||||||
|
id: value,
|
||||||
|
name: name || value,
|
||||||
|
visible: layer.getVisible(),
|
||||||
|
type: "ol",
|
||||||
|
layerRef: layer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤并排序
|
||||||
|
const sortedItems = 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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateLayers();
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
const layerCollection = map.getLayers();
|
||||||
|
layerCollection.on("change:length", updateLayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (map) {
|
||||||
|
map.getLayers().un("change:length", updateLayers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [map, updateLayers]);
|
||||||
|
|
||||||
|
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
|
||||||
|
if (item.type === "ol") {
|
||||||
|
item.layerRef.setVisible(checked);
|
||||||
|
} else if (item.type === "deck" && deckLayer) {
|
||||||
|
if (item.id === "junctionContourLayer") {
|
||||||
|
setShowContourLayer && setShowContourLayer(checked);
|
||||||
|
}
|
||||||
|
if (item.id === "waterflowLayer") {
|
||||||
|
setShowWaterflowLayer && setShowWaterflowLayer(checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayerItems((prev) =>
|
||||||
|
prev.map((i) => (i.id === item.id ? { ...i, visible: checked } : i)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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="ml-3 grid grid-cols-3">
|
||||||
|
{layerItems.map((item) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={item.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={item.visible}
|
||||||
|
onChange={(e) => handleVisibilityChange(item, e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={item.name}
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
"& .MuiFormControlLabel-label": { fontSize: "0.7rem" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayerControl;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useMap } from "../MapComponent";
|
||||||
|
|
||||||
|
const Scale: React.FC = () => {
|
||||||
|
const map = useMap();
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(0);
|
||||||
|
const [coordinates, setCoordinates] = useState<[number, number]>([0, 0]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const updateZoomLevel = () => {
|
||||||
|
const zoom = map.getView().getZoom();
|
||||||
|
setZoomLevel(zoom ?? 0); // 如果 zoom 是 undefined,则使用默认值 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCoordinates = (event: any) => {
|
||||||
|
const coords = event.coordinate;
|
||||||
|
const transformedCoords = coords.map((c: number) =>
|
||||||
|
parseFloat(c.toFixed(4))
|
||||||
|
);
|
||||||
|
setCoordinates(transformedCoords);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("moveend", updateZoomLevel);
|
||||||
|
map.on("pointermove", updateCoordinates);
|
||||||
|
|
||||||
|
// Initialize values
|
||||||
|
updateZoomLevel();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.un("moveend", updateZoomLevel);
|
||||||
|
map.un("pointermove", updateCoordinates);
|
||||||
|
};
|
||||||
|
}, [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]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Scale;
|
||||||
+179
-151
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
// 导入Material-UI图标和组件
|
// 导入Material-UI图标和组件
|
||||||
import ColorLensIcon from "@mui/icons-material/ColorLens";
|
import ColorLensIcon from "@mui/icons-material/ColorLens";
|
||||||
@@ -174,27 +174,36 @@ const hexToRgba = (hex: string) => {
|
|||||||
: "rgba(0, 0, 0, 1)";
|
: "rgba(0, 0, 0, 1)";
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
const StyleEditorPanel: React.FC<StyleEditorPanelProps> = (props) => {
|
||||||
layerStyleStates,
|
|
||||||
setLayerStyleStates,
|
|
||||||
}) => {
|
|
||||||
const map = useMap();
|
|
||||||
const data = useData();
|
const data = useData();
|
||||||
const currentJunctionCalData = data?.currentJunctionCalData;
|
if (!data) {
|
||||||
const currentPipeCalData = data?.currentPipeCalData;
|
return <div>Loading...</div>; // 或其他占位符
|
||||||
const junctionText = data?.junctionText ?? "";
|
}
|
||||||
const pipeText = data?.pipeText ?? "";
|
|
||||||
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
return <StyleEditorPanelContent {...props} data={data} />;
|
||||||
const setShowPipeTextLayer = data?.setShowPipeTextLayer;
|
};
|
||||||
const setShowJunctionId = data?.setShowJunctionId;
|
|
||||||
const setShowPipeId = data?.setShowPipeId;
|
const StyleEditorPanelContent: React.FC<
|
||||||
const setContourLayerAvailable = data?.setContourLayerAvailable;
|
StyleEditorPanelProps & { data: NonNullable<ReturnType<typeof useData>> }
|
||||||
const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable;
|
> = ({ layerStyleStates, setLayerStyleStates, data }) => {
|
||||||
const setJunctionText = data?.setJunctionText;
|
const map = useMap();
|
||||||
const setPipeText = data?.setPipeText;
|
const {
|
||||||
const setContours = data?.setContours;
|
currentJunctionCalData,
|
||||||
const diameterRange = data?.diameterRange;
|
currentPipeCalData,
|
||||||
const elevationRange = data?.elevationRange;
|
junctionText,
|
||||||
|
pipeText,
|
||||||
|
setShowJunctionTextLayer,
|
||||||
|
setShowPipeTextLayer,
|
||||||
|
setShowJunctionId,
|
||||||
|
setShowPipeId,
|
||||||
|
setContourLayerAvailable,
|
||||||
|
setWaterflowLayerAvailable,
|
||||||
|
setJunctionText,
|
||||||
|
setPipeText,
|
||||||
|
setContours,
|
||||||
|
diameterRange,
|
||||||
|
elevationRange,
|
||||||
|
} = data;
|
||||||
|
|
||||||
const unitHeadlossRange = [0, 5];
|
const unitHeadlossRange = [0, 5];
|
||||||
|
|
||||||
@@ -208,6 +217,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]);
|
const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]);
|
||||||
const [selectedRenderLayer, setSelectedRenderLayer] =
|
const [selectedRenderLayer, setSelectedRenderLayer] =
|
||||||
useState<WebGLVectorTileLayer>();
|
useState<WebGLVectorTileLayer>();
|
||||||
|
const [availableProperties, setAvailableProperties] = useState<
|
||||||
|
{ name: string; value: string }[]
|
||||||
|
>([]);
|
||||||
const [styleConfig, setStyleConfig] = useState<StyleConfig>({
|
const [styleConfig, setStyleConfig] = useState<StyleConfig>({
|
||||||
property: "",
|
property: "",
|
||||||
classificationMethod: "pretty_breaks",
|
classificationMethod: "pretty_breaks",
|
||||||
@@ -229,74 +241,6 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
customColors: [],
|
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(
|
const generateGradientColors = useCallback(
|
||||||
(segments: number): string[] => {
|
(segments: number): string[] => {
|
||||||
@@ -321,7 +265,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
}
|
}
|
||||||
return colors;
|
return colors;
|
||||||
},
|
},
|
||||||
[styleConfig.gradientPaletteIndex]
|
[styleConfig.gradientPaletteIndex, parseColor]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 根据分段数生成彩虹色
|
// 根据分段数生成彩虹色
|
||||||
@@ -338,7 +282,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
[styleConfig.rainbowPaletteIndex]
|
[styleConfig.rainbowPaletteIndex]
|
||||||
);
|
);
|
||||||
// 保存当前图层的样式状态
|
// 保存当前图层的样式状态
|
||||||
const saveLayerStyle = (
|
const saveLayerStyle = useCallback(
|
||||||
|
(
|
||||||
layerId?: string,
|
layerId?: string,
|
||||||
newLegendConfig?: LegendStyleConfig,
|
newLegendConfig?: LegendStyleConfig,
|
||||||
overrideStyleConfig?: StyleConfig
|
overrideStyleConfig?: StyleConfig
|
||||||
@@ -349,8 +294,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
console.warn("无法保存样式:缺少必要的图层或样式配置");
|
console.warn("无法保存样式:缺少必要的图层或样式配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!layerId) return;
|
if (!layerId) return; // 如果没有传入 layerId,则不保存
|
||||||
|
// 如果没有传入图例配置,则创建一个默认的空配置
|
||||||
const layerName =
|
const layerName =
|
||||||
newLegendConfig?.layerName ||
|
newLegendConfig?.layerName ||
|
||||||
selectedRenderLayer?.get("name") ||
|
selectedRenderLayer?.get("name") ||
|
||||||
@@ -358,7 +303,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
const property = availableProperties.find(
|
const property = availableProperties.find(
|
||||||
(p) => p.value === currentStyleConfig.property
|
(p) => p.value === currentStyleConfig.property
|
||||||
);
|
);
|
||||||
const legendConfig: LegendStyleConfig = newLegendConfig || {
|
let legendConfig: LegendStyleConfig = newLegendConfig || {
|
||||||
layerId,
|
layerId,
|
||||||
layerName,
|
layerName,
|
||||||
property: property?.name || currentStyleConfig.property,
|
property: property?.name || currentStyleConfig.property,
|
||||||
@@ -375,19 +320,25 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
legendConfig: { ...legendConfig },
|
legendConfig: { ...legendConfig },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
setLayerStyleStates((prev) => {
|
setLayerStyleStates((prev) => {
|
||||||
const existingIndex = prev.findIndex((state) => state.layerId === layerId);
|
// 检查是否已存在该图层的样式状态
|
||||||
|
const existingIndex = prev.findIndex(
|
||||||
|
(state) => state.layerId === layerId
|
||||||
|
);
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
|
// 更新已存在的状态
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[existingIndex] = newStyleState;
|
updated[existingIndex] = newStyleState;
|
||||||
return updated;
|
return updated;
|
||||||
}
|
} else {
|
||||||
|
// 添加新的状态
|
||||||
return [...prev, newStyleState];
|
return [...prev, newStyleState];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[selectedRenderLayer, styleConfig, availableProperties]
|
||||||
|
);
|
||||||
// 设置分类样式参数,触发样式应用
|
// 设置分类样式参数,触发样式应用
|
||||||
const setStyleState = () => {
|
const setStyleState = () => {
|
||||||
if (!selectedRenderLayer) return;
|
if (!selectedRenderLayer) return;
|
||||||
@@ -840,7 +791,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 重置样式
|
// 重置样式
|
||||||
const resetStyle = () => {
|
const resetStyle = useCallback(() => {
|
||||||
if (!selectedRenderLayer) return;
|
if (!selectedRenderLayer) return;
|
||||||
// 重置 WebGL 图层样式
|
// 重置 WebGL 图层样式
|
||||||
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
||||||
@@ -868,7 +819,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
setWaterflowLayerAvailable && setWaterflowLayerAvailable(false);
|
setWaterflowLayerAvailable && setWaterflowLayerAvailable(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [selectedRenderLayer]);
|
||||||
// 更新当前 VectorTileSource 中的所有缓冲要素属性
|
// 更新当前 VectorTileSource 中的所有缓冲要素属性
|
||||||
const updateVectorTileSource = (property: string, data: any[]) => {
|
const updateVectorTileSource = (property: string, data: any[]) => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -910,7 +861,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
|
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
|
||||||
const tileLoadListenersRef = useRef<
|
const [tileLoadListeners, setTileLoadListeners] = useState<
|
||||||
Map<VectorTileSource, (event: any) => void>
|
Map<VectorTileSource, (event: any) => void>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
|
|
||||||
@@ -932,6 +883,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
dataMap.set(d.ID, d.value || 0);
|
dataMap.set(d.ID, d.value || 0);
|
||||||
});
|
});
|
||||||
// 新增监听器并保存
|
// 新增监听器并保存
|
||||||
|
const newListeners = new Map<VectorTileSource, (event: any) => void>();
|
||||||
|
|
||||||
const listener = (event: any) => {
|
const listener = (event: any) => {
|
||||||
try {
|
try {
|
||||||
if (event.tile instanceof VectorTile) {
|
if (event.tile instanceof VectorTile) {
|
||||||
@@ -957,7 +910,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
vectorTileSource.on("tileloadend", listener);
|
vectorTileSource.on("tileloadend", listener);
|
||||||
tileLoadListenersRef.current.set(vectorTileSource, listener);
|
newListeners.set(vectorTileSource, listener);
|
||||||
|
setTileLoadListeners(newListeners);
|
||||||
};
|
};
|
||||||
// 新增函数:取消对应 layerId 已添加的 on 事件
|
// 新增函数:取消对应 layerId 已添加的 on 事件
|
||||||
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
|
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
|
||||||
@@ -968,10 +922,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
.map((layer) => layer.getSource() as VectorTileSource)
|
.map((layer) => layer.getSource() as VectorTileSource)
|
||||||
.filter((source) => source)[0];
|
.filter((source) => source)[0];
|
||||||
if (!vectorTileSource) return;
|
if (!vectorTileSource) return;
|
||||||
const listener = tileLoadListenersRef.current.get(vectorTileSource);
|
const listener = tileLoadListeners.get(vectorTileSource);
|
||||||
if (listener) {
|
if (listener) {
|
||||||
vectorTileSource.un("tileloadend", listener);
|
vectorTileSource.un("tileloadend", listener);
|
||||||
tileLoadListenersRef.current.delete(vectorTileSource);
|
setTileLoadListeners((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.delete(vectorTileSource);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1065,8 +1023,6 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
if (!applyPipeStyle) {
|
if (!applyPipeStyle) {
|
||||||
removeVectorTileSourceLoadedEvent("pipes");
|
removeVectorTileSourceLoadedEvent("pipes");
|
||||||
}
|
}
|
||||||
// This effect is intentionally driven by explicit style triggers and data snapshots.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
}, [
|
||||||
styleUpdateTrigger,
|
styleUpdateTrigger,
|
||||||
applyJunctionStyle,
|
applyJunctionStyle,
|
||||||
@@ -1092,9 +1048,117 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
|
|
||||||
updateVisibleLayers();
|
updateVisibleLayers();
|
||||||
}, [map]);
|
}, [map]);
|
||||||
if (!data) {
|
// 获取选中图层的属性,并检查是否有已缓存的样式状态
|
||||||
return <div>Loading...</div>;
|
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]);
|
||||||
|
|
||||||
const getColorSetting = () => {
|
const getColorSetting = () => {
|
||||||
if (styleConfig.colorType === "single") {
|
if (styleConfig.colorType === "single") {
|
||||||
@@ -1564,21 +1628,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
const cachedStyleState = layerStyleStates.find(
|
const cachedStyleState = layerStyleStates.find(
|
||||||
(state) => state.layerId === layerId
|
(state) => state.layerId === layerId
|
||||||
);
|
);
|
||||||
if (cachedStyleState) {
|
// 只有在没有缓存时才清空属性
|
||||||
setStyleConfig(cachedStyleState.styleConfig);
|
if (!cachedStyleState) {
|
||||||
} else {
|
setStyleConfig((prev) => ({ ...prev, property: "" }));
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
property: "",
|
|
||||||
customBreaks:
|
|
||||||
prev.classificationMethod === "custom_breaks"
|
|
||||||
? getDefaultCustomBreaks(prev.segments, "", newLayer)
|
|
||||||
: prev.customBreaks,
|
|
||||||
customColors: getDefaultCustomColors(
|
|
||||||
prev.segments,
|
|
||||||
prev.customColors
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1599,15 +1651,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
<Select
|
<Select
|
||||||
value={styleConfig.property}
|
value={styleConfig.property}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nextProperty = e.target.value;
|
setStyleConfig((prev) => ({ ...prev, property: e.target.value }));
|
||||||
setStyleConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
property: nextProperty,
|
|
||||||
customBreaks:
|
|
||||||
prev.classificationMethod === "custom_breaks"
|
|
||||||
? getDefaultCustomBreaks(prev.segments, nextProperty)
|
|
||||||
: prev.customBreaks,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
disabled={!selectedRenderLayer}
|
disabled={!selectedRenderLayer}
|
||||||
>
|
>
|
||||||
@@ -1624,14 +1668,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
<Select
|
<Select
|
||||||
value={styleConfig.classificationMethod}
|
value={styleConfig.classificationMethod}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nextMethod = e.target.value;
|
|
||||||
setStyleConfig((prev) => ({
|
setStyleConfig((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
classificationMethod: nextMethod,
|
classificationMethod: e.target.value,
|
||||||
customBreaks:
|
|
||||||
nextMethod === "custom_breaks"
|
|
||||||
? getDefaultCustomBreaks(prev.segments, prev.property)
|
|
||||||
: prev.customBreaks,
|
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1660,14 +1699,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
segments: newSegments,
|
segments: newSegments,
|
||||||
customBreaks:
|
customColors: newCustomColors,
|
||||||
prev.classificationMethod === "custom_breaks"
|
|
||||||
? getDefaultCustomBreaks(newSegments, prev.property)
|
|
||||||
: prev.customBreaks,
|
|
||||||
customColors: getDefaultCustomColors(
|
|
||||||
newSegments,
|
|
||||||
newCustomColors
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1754,10 +1786,6 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
colorType: newColorType,
|
colorType: newColorType,
|
||||||
adjustWidthByProperty:
|
|
||||||
newColorType === "single"
|
|
||||||
? true
|
|
||||||
: prev.adjustWidthByProperty,
|
|
||||||
customColors: newCustomColors,
|
customColors: newCustomColors,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
+2
-27
@@ -10,9 +10,6 @@ interface LegendStyleConfig {
|
|||||||
type: string; // 图例类型
|
type: string; // 图例类型
|
||||||
dimensions: number[]; // 尺寸大小
|
dimensions: number[]; // 尺寸大小
|
||||||
breaks: number[]; // 分段值
|
breaks: number[]; // 分段值
|
||||||
labels?: string[]; // 可选标签(用于离散分类)
|
|
||||||
columns?: number;
|
|
||||||
itemsPerColumn?: number;
|
|
||||||
}
|
}
|
||||||
// 图例组件
|
// 图例组件
|
||||||
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
||||||
@@ -27,9 +24,6 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
type, // 图例类型
|
type, // 图例类型
|
||||||
dimensions,
|
dimensions,
|
||||||
breaks,
|
breaks,
|
||||||
labels,
|
|
||||||
columns = 1,
|
|
||||||
itemsPerColumn,
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -39,23 +33,6 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
<Typography variant="subtitle2" gutterBottom>
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
{layerName} - {property}
|
{layerName} - {property}
|
||||||
</Typography>
|
</Typography>
|
||||||
<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) => {
|
{[...Array(breaks.length)].map((_, index) => {
|
||||||
const color = colors[index]; // 默认颜色为黑色
|
const color = colors[index]; // 默认颜色为黑色
|
||||||
const dimension = dimensions[index]; // 默认尺寸为16
|
const dimension = dimensions[index]; // 默认尺寸为16
|
||||||
@@ -93,7 +70,7 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
const prevValue = breaks[index];
|
const prevValue = breaks[index];
|
||||||
const currentValue = breaks[index + 1];
|
const currentValue = breaks[index + 1];
|
||||||
return (
|
return (
|
||||||
<Box key={index} className="flex items-center gap-2">
|
<Box key={index} className="flex items-center gap-2 mb-1">
|
||||||
<Box
|
<Box
|
||||||
sx={
|
sx={
|
||||||
type === "point"
|
type === "point"
|
||||||
@@ -112,8 +89,7 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" className="text-xs">
|
<Typography variant="caption" className="text-xs">
|
||||||
{labels?.[index] ??
|
{prevValue?.toFixed(1)} - {currentValue?.toFixed(1)}
|
||||||
`${prevValue?.toFixed(1)} - ${currentValue?.toFixed(1)}`}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -122,7 +98,6 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
+70
-64
@@ -28,7 +28,6 @@ import { TbRewindBackward15, TbRewindForward15 } from "react-icons/tb";
|
|||||||
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
||||||
import { useData } from "../MapComponent";
|
import { useData } from "../MapComponent";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
|
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
@@ -39,33 +38,49 @@ interface TimelineProps {
|
|||||||
schemeType?: string;
|
schemeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
const Timeline: React.FC<TimelineProps> = (props) => {
|
||||||
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
const data = useData();
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
data.setCurrentTime === undefined ||
|
||||||
|
data.currentTime === undefined ||
|
||||||
|
data.selectedDate === undefined ||
|
||||||
|
data.setSelectedDate === undefined
|
||||||
|
) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
const Timeline: React.FC<TimelineProps> = ({
|
return <TimelineContent {...props} data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimelineContent: React.FC<
|
||||||
|
TimelineProps & { data: NonNullable<ReturnType<typeof useData>> }
|
||||||
|
> = ({
|
||||||
schemeDate,
|
schemeDate,
|
||||||
timeRange,
|
timeRange,
|
||||||
disableDateSelection = false,
|
disableDateSelection = false,
|
||||||
schemeName = "",
|
schemeName = "",
|
||||||
schemeType = "burst_analysis",
|
schemeType = "burst_Analysis",
|
||||||
|
data,
|
||||||
}) => {
|
}) => {
|
||||||
const data = useData();
|
const readyData = data as typeof data & {
|
||||||
const fallbackSelectedDateRef = useRef(new Date());
|
currentTime: NonNullable<typeof data.currentTime>;
|
||||||
const hasTimelineState =
|
setCurrentTime: NonNullable<typeof data.setCurrentTime>;
|
||||||
data &&
|
selectedDate: NonNullable<typeof data.selectedDate>;
|
||||||
data.setCurrentTime !== undefined &&
|
setSelectedDate: NonNullable<typeof data.setSelectedDate>;
|
||||||
data.currentTime !== undefined &&
|
};
|
||||||
data.selectedDate !== undefined &&
|
const {
|
||||||
data.setSelectedDate !== undefined;
|
currentTime,
|
||||||
const currentTime = data?.currentTime ?? -1;
|
setCurrentTime,
|
||||||
const setCurrentTime = data?.setCurrentTime ?? NOOP_SET_CURRENT_TIME;
|
selectedDate,
|
||||||
const selectedDate = data?.selectedDate ?? fallbackSelectedDateRef.current;
|
setSelectedDate,
|
||||||
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
|
setCurrentJunctionCalData,
|
||||||
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
|
setCurrentPipeCalData,
|
||||||
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
|
junctionText,
|
||||||
const junctionText = data?.junctionText ?? "";
|
pipeText,
|
||||||
const pipeText = data?.pipeText ?? "";
|
} = readyData;
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
|
||||||
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟
|
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟
|
||||||
@@ -82,7 +97,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (schemeDate) {
|
if (schemeDate) {
|
||||||
setSelectedDate(schemeDate);
|
setSelectedDate(schemeDate);
|
||||||
}
|
}
|
||||||
}, [schemeDate, setSelectedDate]);
|
}, [schemeDate]);
|
||||||
// 新增:用于 Draggable 的 nodeRef
|
// 新增:用于 Draggable 的 nodeRef
|
||||||
const draggableRef = useRef<HTMLDivElement>(null);
|
const draggableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -94,20 +109,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 添加防抖引用
|
// 添加防抖引用
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => {
|
const fetchFrameData = async (
|
||||||
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,
|
queryTime: Date,
|
||||||
junctionProperties: string,
|
junctionProperties: string,
|
||||||
pipeProperties: string,
|
pipeProperties: string,
|
||||||
@@ -127,11 +129,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
disableDateSelection && schemeName
|
disableDateSelection && schemeName
|
||||||
? (nodePromise = apiFetch(
|
? (nodePromise = fetch(
|
||||||
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}`
|
// `${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}`,
|
`${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(
|
: (nodePromise = fetch(
|
||||||
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
|
// `${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}`,
|
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||||
));
|
));
|
||||||
@@ -148,11 +150,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
disableDateSelection && schemeName
|
disableDateSelection && schemeName
|
||||||
? (linkPromise = apiFetch(
|
? (linkPromise = fetch(
|
||||||
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}`
|
// `${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}`,
|
`${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(
|
: (linkPromise = fetch(
|
||||||
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
|
// `${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}`,
|
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
|
||||||
));
|
));
|
||||||
@@ -187,7 +189,21 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
// 更新状态
|
// 更新状态
|
||||||
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
|
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
|
||||||
}, [disableDateSelection, updateDataStates]);
|
};
|
||||||
|
|
||||||
|
// 提取更新状态的逻辑
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 时间刻度数组 (每5分钟一个刻度)
|
// 时间刻度数组 (每5分钟一个刻度)
|
||||||
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
||||||
@@ -244,7 +260,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
setCurrentTime(value);
|
setCurrentTime(value);
|
||||||
}, 500); // 500ms 防抖延迟
|
}, 500); // 500ms 防抖延迟
|
||||||
},
|
},
|
||||||
[timeRange, minTime, maxTime, setCurrentTime],
|
[timeRange, minTime, maxTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 播放控制
|
// 播放控制
|
||||||
@@ -271,7 +287,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
});
|
});
|
||||||
}, playInterval);
|
}, playInterval);
|
||||||
}
|
}
|
||||||
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]);
|
}, [isPlaying, playInterval]);
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -291,7 +307,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
}, [setCurrentTime]);
|
}, []);
|
||||||
|
|
||||||
// 步进控制
|
// 步进控制
|
||||||
const handleDayStepBackward = useCallback(() => {
|
const handleDayStepBackward = useCallback(() => {
|
||||||
@@ -300,14 +316,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
newDate.setDate(newDate.getDate() - 1);
|
newDate.setDate(newDate.getDate() - 1);
|
||||||
return newDate;
|
return newDate;
|
||||||
});
|
});
|
||||||
}, [setSelectedDate]);
|
}, []);
|
||||||
const handleDayStepForward = useCallback(() => {
|
const handleDayStepForward = useCallback(() => {
|
||||||
setSelectedDate((prev) => {
|
setSelectedDate((prev) => {
|
||||||
const newDate = new Date(prev);
|
const newDate = new Date(prev);
|
||||||
newDate.setDate(newDate.getDate() + 1);
|
newDate.setDate(newDate.getDate() + 1);
|
||||||
return newDate;
|
return newDate;
|
||||||
});
|
});
|
||||||
}, [setSelectedDate]);
|
}, []);
|
||||||
const handleStepBackward = useCallback(() => {
|
const handleStepBackward = useCallback(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
let next = prev - 15;
|
let next = prev - 15;
|
||||||
@@ -318,7 +334,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
}, [timeRange, minTime, maxTime]);
|
||||||
|
|
||||||
const handleStepForward = useCallback(() => {
|
const handleStepForward = useCallback(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
@@ -330,14 +346,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
}, [timeRange, minTime, maxTime]);
|
||||||
|
|
||||||
// 日期选择处理
|
// 日期选择处理
|
||||||
const handleDateChange = useCallback((newDate: Date | null) => {
|
const handleDateChange = useCallback((newDate: Date | null) => {
|
||||||
if (newDate) {
|
if (newDate) {
|
||||||
setSelectedDate(newDate);
|
setSelectedDate(newDate);
|
||||||
}
|
}
|
||||||
}, [setSelectedDate]);
|
}, []);
|
||||||
|
|
||||||
// 播放间隔改变处理
|
// 播放间隔改变处理
|
||||||
const handleIntervalChange = useCallback(
|
const handleIntervalChange = useCallback(
|
||||||
@@ -361,7 +377,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, newInterval);
|
}, newInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, timeRange, maxTime, minTime, setCurrentTime],
|
[isPlaying],
|
||||||
);
|
);
|
||||||
// 计算时间段改变处理
|
// 计算时间段改变处理
|
||||||
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
||||||
@@ -392,7 +408,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
fetchFrameData,
|
|
||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -418,14 +433,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [setCurrentTime]);
|
}, []);
|
||||||
|
|
||||||
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
// 当 timeRange 改变时,设置 currentTime 到 minTime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timeRange) {
|
if (timeRange) {
|
||||||
setCurrentTime(minTime);
|
setCurrentTime(minTime);
|
||||||
}
|
}
|
||||||
}, [timeRange, minTime, setCurrentTime]);
|
}, [timeRange, minTime]);
|
||||||
// 获取地图实例
|
// 获取地图实例
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
||||||
@@ -510,7 +525,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
duration: calculatedInterval,
|
duration: calculatedInterval,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiFetch(
|
const response = await fetch(
|
||||||
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
|
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -545,10 +560,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!hasTimelineState) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
||||||
<div
|
<div
|
||||||
@@ -605,7 +616,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
|
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
|
||||||
>
|
>
|
||||||
<Tooltip title="后退一天">
|
<Tooltip title="后退一天">
|
||||||
<span>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDayStepBackward}
|
onClick={handleDayStepBackward}
|
||||||
@@ -614,7 +624,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 日期选择器 */}
|
{/* 日期选择器 */}
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -635,20 +644,17 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
disabled={disableDateSelection}
|
disabled={disableDateSelection}
|
||||||
/>
|
/>
|
||||||
<Tooltip title="前进一天">
|
<Tooltip title="前进一天">
|
||||||
<span>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDayStepForward}
|
onClick={handleDayStepForward}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={
|
disabled={
|
||||||
disableDateSelection ||
|
disableDateSelection ||
|
||||||
selectedDate.toDateString() ===
|
selectedDate.toDateString() === new Date().toDateString()
|
||||||
new Date().toDateString()
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FiSkipForward />
|
<FiSkipForward />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 播放控制按钮 */}
|
{/* 播放控制按钮 */}
|
||||||
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
||||||
+29
-162
@@ -8,42 +8,43 @@ import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
|
|||||||
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
||||||
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
||||||
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
||||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
|
||||||
|
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import { Style, Stroke, Fill, Circle } from "ol/style";
|
import { Style, Stroke, Fill, Circle } from "ol/style";
|
||||||
import Feature from "ol/Feature";
|
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 StyleEditorPanel from "./StyleEditorPanel";
|
||||||
import { LayerStyleState } from "./StyleEditorPanel";
|
import { LayerStyleState } from "./StyleEditorPanel";
|
||||||
import StyleLegend from "./StyleLegend"; // 引入图例组件
|
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 { useNotification } from "@refinedev/core";
|
||||||
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
|
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
|
||||||
|
|
||||||
// 添加接口定义隐藏按钮的props
|
// 添加接口定义隐藏按钮的props
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
|
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
|
||||||
queryType?: string; // 可选的查询类型参数
|
queryType?: string; // 可选的查询类型参数
|
||||||
schemeType?: string; // 可选的方案类型参数
|
|
||||||
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
||||||
}
|
}
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
const Toolbar: React.FC<ToolbarProps> = (props) => {
|
||||||
|
const data = useData();
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return <ToolbarContent {...props} data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolbarContent: React.FC<
|
||||||
|
ToolbarProps & { data: NonNullable<ReturnType<typeof useData>> }
|
||||||
|
> = ({
|
||||||
hiddenButtons,
|
hiddenButtons,
|
||||||
queryType,
|
queryType,
|
||||||
schemeType,
|
|
||||||
HistoryPanel,
|
HistoryPanel,
|
||||||
|
data,
|
||||||
}) => {
|
}) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
const { currentTime, selectedDate, schemeName } = data;
|
||||||
const [activeTools, setActiveTools] = useState<string[]>([]);
|
const [activeTools, setActiveTools] = useState<string[]>([]);
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
const [showPropertyPanel, setShowPropertyPanel] = useState<boolean>(false);
|
const [showPropertyPanel, setShowPropertyPanel] = useState<boolean>(false);
|
||||||
@@ -52,106 +53,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const [showHistoryPanel, setShowHistoryPanel] = useState<boolean>(false);
|
const [showHistoryPanel, setShowHistoryPanel] = useState<boolean>(false);
|
||||||
const [highlightLayer, setHighlightLayer] =
|
const [highlightLayer, setHighlightLayer] =
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
const currentTime = data?.currentTime;
|
|
||||||
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[],
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[map],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
|
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
|
||||||
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
|
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
|
||||||
@@ -430,8 +331,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
case "history":
|
case "history":
|
||||||
setShowHistoryPanel(false);
|
setShowHistoryPanel(false);
|
||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
setChatPanelFeatureInfos(null);
|
|
||||||
setChatPanelTimeRange(null);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -458,8 +357,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
setShowDrawPanel(false);
|
setShowDrawPanel(false);
|
||||||
setShowHistoryPanel(false);
|
setShowHistoryPanel(false);
|
||||||
setChatPanelFeatureInfos(null);
|
|
||||||
setChatPanelTimeRange(null);
|
|
||||||
// 样式编辑器保持其当前状态,不自动关闭
|
// 样式编辑器保持其当前状态,不自动关闭
|
||||||
};
|
};
|
||||||
const [computedProperties, setComputedProperties] = useState<
|
const [computedProperties, setComputedProperties] = useState<
|
||||||
@@ -497,12 +394,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
||||||
let response;
|
let response;
|
||||||
if (queryType === "scheme") {
|
if (queryType === "scheme") {
|
||||||
response = await apiFetch(
|
response = await fetch(
|
||||||
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
// `${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}`,
|
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
response = await apiFetch(
|
response = await fetch(
|
||||||
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
|
// `${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}`,
|
`${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`,
|
||||||
);
|
);
|
||||||
@@ -511,12 +408,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
throw new Error("API request failed");
|
throw new Error("API request failed");
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.result || data.result.length === 0) {
|
setComputedProperties(data.results[0] || {});
|
||||||
setComputedProperties({});
|
|
||||||
} else {
|
|
||||||
setComputedProperties(data.result[0] || {});
|
|
||||||
// console.log("查询到的计算属性:", data.result[0]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error querying computed properties:", error);
|
console.error("Error querying computed properties:", error);
|
||||||
setComputedProperties({});
|
setComputedProperties({});
|
||||||
@@ -524,7 +416,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
};
|
};
|
||||||
// 仅当 currentTime 有效时查询
|
// 仅当 currentTime 有效时查询
|
||||||
if (currentTime !== -1 && queryType) queryComputedProperties();
|
if (currentTime !== -1 && queryType) queryComputedProperties();
|
||||||
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
}, [highlightFeatures, currentTime, selectedDate]);
|
||||||
|
|
||||||
// 从要素属性中提取属性面板需要的数据
|
// 从要素属性中提取属性面板需要的数据
|
||||||
const getFeatureProperties = useCallback(() => {
|
const getFeatureProperties = useCallback(() => {
|
||||||
@@ -534,7 +426,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const properties = highlightFeature.getProperties();
|
const properties = highlightFeature.getProperties();
|
||||||
// 计算属性字段,增加 key 字段
|
// 计算属性字段,增加 key 字段
|
||||||
const pipeComputedFields = [
|
const pipeComputedFields = [
|
||||||
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
|
{ key: "flow", label: "流量", unit: "m³/h" },
|
||||||
{ key: "friction", label: "摩阻", unit: "" },
|
{ key: "friction", label: "摩阻", unit: "" },
|
||||||
{ key: "headloss", label: "水头损失", unit: "m" },
|
{ key: "headloss", label: "水头损失", unit: "m" },
|
||||||
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
|
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
|
||||||
@@ -545,7 +437,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
{ key: "velocity", label: "流速", unit: "m/s" },
|
{ key: "velocity", label: "流速", unit: "m/s" },
|
||||||
];
|
];
|
||||||
const nodeComputedFields = [
|
const nodeComputedFields = [
|
||||||
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
|
{ key: "actual_demand", label: "实际需水量", unit: "m³/h" },
|
||||||
{ key: "total_head", label: "水头", unit: "m" },
|
{ key: "total_head", label: "水头", unit: "m" },
|
||||||
{ key: "pressure", label: "压力", unit: "m" },
|
{ key: "pressure", label: "压力", unit: "m" },
|
||||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
{ key: "quality", label: "水质", unit: "mg/L" },
|
||||||
@@ -573,11 +465,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
if (computedProperties) {
|
if (computedProperties) {
|
||||||
pipeComputedFields.forEach(({ key, label, unit }) => {
|
pipeComputedFields.forEach(({ key, label, unit }) => {
|
||||||
let value = computedProperties[key];
|
let value = computedProperties[key];
|
||||||
|
|
||||||
if (key === "flow" && value !== undefined) {
|
|
||||||
value = toM3h(value, "lps");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
|
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
|
||||||
if (
|
if (
|
||||||
key === "unit_headloss" &&
|
key === "unit_headloss" &&
|
||||||
@@ -616,11 +503,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
columns: ["demand", "pattern"],
|
columns: ["demand", "pattern"],
|
||||||
rows: Array.from({ length: 5 }, (_, i) => i + 1)
|
rows: Array.from({ length: 5 }, (_, i) => i + 1)
|
||||||
.map((idx) => {
|
.map((idx) => {
|
||||||
let d = properties?.[`demand${idx}`];
|
const d = properties?.[`demand${idx}`]?.toFixed?.(3);
|
||||||
const p = properties?.[`pattern${idx}`];
|
const p = properties?.[`pattern${idx}`];
|
||||||
// 仅当 demand 有效时展示该行
|
// 仅当 demand 有效时展示该行
|
||||||
if (d !== undefined && d !== null && d !== "") {
|
if (d !== undefined && d !== null && d !== "") {
|
||||||
d = toM3h(Number(d), "lps");
|
|
||||||
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
|
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -632,14 +518,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
if (computedProperties) {
|
if (computedProperties) {
|
||||||
nodeComputedFields.forEach(({ key, label, unit }) => {
|
nodeComputedFields.forEach(({ key, label, unit }) => {
|
||||||
if (computedProperties[key] !== undefined) {
|
if (computedProperties[key] !== undefined) {
|
||||||
let value = computedProperties[key];
|
|
||||||
if (key === "actual_demand") {
|
|
||||||
value = toM3h(value, "lps");
|
|
||||||
}
|
|
||||||
result.properties.push({
|
result.properties.push({
|
||||||
label,
|
label,
|
||||||
value:
|
value:
|
||||||
value?.toFixed?.(3) || value,
|
computedProperties[key].toFixed?.(3) || computedProperties[key],
|
||||||
unit,
|
unit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -827,10 +709,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
return {};
|
return {};
|
||||||
}, [highlightFeatures, computedProperties]);
|
}, [highlightFeatures, computedProperties]);
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity">
|
<div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity">
|
||||||
@@ -876,16 +754,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showHistoryPanel &&
|
{showHistoryPanel &&
|
||||||
(chatPanelType === "none" && chatPanelFeatureInfos ? (
|
(HistoryPanel ? (
|
||||||
<SCADADataPanel
|
|
||||||
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
|
|
||||||
visible={showHistoryPanel}
|
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
|
||||||
/>
|
|
||||||
) : HistoryPanel ? (
|
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
featureInfos={chatPanelFeatureInfos ?? (() => {
|
featureInfos={(() => {
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
@@ -921,15 +792,13 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as [string, string][];
|
.filter(Boolean) as [string, string][];
|
||||||
})()}
|
})()}
|
||||||
scheme_type="burst_analysis"
|
scheme_type="burst_Analysis"
|
||||||
scheme_name={schemeName}
|
scheme_name={schemeName}
|
||||||
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
type={queryType as "realtime" | "scheme" | "none"}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HistoryDataPanel
|
<HistoryDataPanel
|
||||||
featureInfos={chatPanelFeatureInfos ?? (() => {
|
featureInfos={(() => {
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
@@ -965,11 +834,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as [string, string][];
|
.filter(Boolean) as [string, string][];
|
||||||
})()}
|
})()}
|
||||||
scheme_type="burst_analysis"
|
scheme_type="burst_Analysis"
|
||||||
scheme_name={schemeName}
|
scheme_name={schemeName}
|
||||||
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
type={queryType as "realtime" | "scheme" | "none"}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ const Zoom: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-4 bottom-11 z-20">
|
<div className="absolute right-4 bottom-8 z-1300">
|
||||||
<div className="w-8 h-26 flex flex-col gap-2 items-center">
|
<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">
|
<div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black">
|
||||||
<button
|
<button
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
import { useProject } from "@/contexts/ProjectContext";
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -33,7 +32,6 @@ import { Icon, Style } from "ol/style.js";
|
|||||||
import { FeatureLike } from "ol/Feature";
|
import { FeatureLike } from "ol/Feature";
|
||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { ContourLayer } from "deck.gl";
|
import { ContourLayer } from "deck.gl";
|
||||||
import { toM3h } from "@utils/units";
|
|
||||||
|
|
||||||
interface MapComponentProps {
|
interface MapComponentProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -77,34 +75,20 @@ interface DataContextType {
|
|||||||
const MapContext = createContext<OlMap | undefined>(undefined);
|
const MapContext = createContext<OlMap | undefined>(undefined);
|
||||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
const DataContext = createContext<DataContextType | undefined>(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
|
||||||
// 添加防抖函数
|
// 添加防抖函数
|
||||||
type DebouncedFunction<F extends (...args: any[]) => any> = ((
|
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
||||||
...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;
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const debounced = (...args: Parameters<F>): void => {
|
return (...args: Parameters<F>): void => {
|
||||||
if (timeout !== null) {
|
if (timeout !== null) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
timeout = setTimeout(() => func(...args), waitFor);
|
timeout = setTimeout(() => func(...args), waitFor);
|
||||||
};
|
};
|
||||||
|
|
||||||
debounced.cancel = () => {
|
|
||||||
if (timeout !== null) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return debounced;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMap = () => {
|
export const useMap = () => {
|
||||||
@@ -115,22 +99,8 @@ export const useData = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||||
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);
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const deckLayerRef = useRef<DeckLayer | null>(null);
|
const deckLayerRef = useRef<DeckLayer | null>(null);
|
||||||
const isDisposingRef = useRef(false);
|
|
||||||
const pendingTimeoutsRef = useRef<number[]>([]);
|
|
||||||
|
|
||||||
const [map, setMap] = useState<OlMap>();
|
const [map, setMap] = useState<OlMap>();
|
||||||
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
||||||
@@ -173,12 +143,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r]));
|
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r]));
|
||||||
return junctionData.map((j) => {
|
return junctionData.map((j) => {
|
||||||
const record = nodeMap.get(j.id);
|
const record = nodeMap.get(j.id);
|
||||||
let val = record ? record.value : undefined;
|
return record ? { ...j, [junctionText]: record.value } : j;
|
||||||
// 在这合并时将实际需水量从 LPS 转换为大写表示
|
|
||||||
if (val !== undefined && junctionText === "actualdemand") {
|
|
||||||
val = toM3h(val, "lps");
|
|
||||||
}
|
|
||||||
return record ? { ...j, [junctionText]: val } : j;
|
|
||||||
});
|
});
|
||||||
}, [junctionData, currentJunctionCalData, junctionText]);
|
}, [junctionData, currentJunctionCalData, junctionText]);
|
||||||
|
|
||||||
@@ -188,13 +153,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const record = linkMap.get(p.id);
|
const record = linkMap.get(p.id);
|
||||||
if (!record) return p;
|
if (!record) return p;
|
||||||
const isFlow = pipeText === "flow";
|
const isFlow = pipeText === "flow";
|
||||||
let val = record.value;
|
|
||||||
if (val !== undefined && isFlow) {
|
|
||||||
val = toM3h(val, "lps");
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
[pipeText]: isFlow ? Math.abs(val) : val,
|
[pipeText]: isFlow ? Math.abs(record.value) : record.value,
|
||||||
flowFlag: isFlow && record.value < 0 ? -1 : 1,
|
flowFlag: isFlow && record.value < 0 ? -1 : 1,
|
||||||
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
|
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
|
||||||
};
|
};
|
||||||
@@ -208,6 +169,20 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
[number, number] | undefined
|
[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 setJunctionData = (newData: any[]) => {
|
||||||
const uniqueNewData = newData.filter((item) => {
|
const uniqueNewData = newData.filter((item) => {
|
||||||
if (!item || !item.id) return false;
|
if (!item || !item.id) return false;
|
||||||
@@ -239,7 +214,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPipeData = (newData: any[]) => {
|
const setPipeData = (newData: any[]) => {
|
||||||
const uniqueNewData = newData.filter((item) => {
|
const uniqueNewData = newData.filter((item) => {
|
||||||
if (!item || !item.id) return false;
|
if (!item || !item.id) return false;
|
||||||
@@ -271,28 +245,6 @@ 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;
|
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
||||||
// 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标
|
// 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标
|
||||||
@@ -507,7 +459,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const scadaLayer = new VectorLayer({
|
const scadaLayer = new VectorLayer({
|
||||||
source: scadaSource,
|
source: scadaSource,
|
||||||
style: scadaStyle,
|
style: scadaStyle,
|
||||||
extent: MAP_EXTENT, // 设置图层范围
|
// extent: extent, // 设置图层范围
|
||||||
maxZoom: 24,
|
maxZoom: 24,
|
||||||
minZoom: 11,
|
minZoom: 11,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -518,40 +470,16 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current) return;
|
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 提供坐标供标签显示
|
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
|
||||||
const handleJunctionTileLoadEnd = (event: any) => {
|
junctionSource.on("tileloadend", (event) => {
|
||||||
if (isDisposingRef.current) return;
|
|
||||||
try {
|
try {
|
||||||
if (event.tile instanceof VectorTile) {
|
if (event.tile instanceof VectorTile) {
|
||||||
const renderFeatures = event.tile.getFeatures();
|
const renderFeatures = event.tile.getFeatures();
|
||||||
const data = new Map();
|
const data = new Map();
|
||||||
|
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
renderFeatures.forEach((renderFeature) => {
|
||||||
const props = renderFeature.getProperties();
|
const props = renderFeature.getProperties();
|
||||||
const featureId = props.id;
|
const featureId = props.id;
|
||||||
if (featureId && !junctionDataIds.current.has(featureId)) {
|
if (featureId && !junctionDataIds.current.has(featureId)) {
|
||||||
@@ -574,21 +502,20 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
uniqueData.forEach((item) =>
|
uniqueData.forEach((item) =>
|
||||||
tileJunctionDataBuffer.current.push(item),
|
tileJunctionDataBuffer.current.push(item),
|
||||||
);
|
);
|
||||||
debouncedUpdateDataRef.current?.();
|
debouncedUpdateData.current();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Junction tile load error:", error);
|
console.error("Junction tile load error:", error);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
const handlePipeTileLoadEnd = (event: any) => {
|
pipeSource.on("tileloadend", (event) => {
|
||||||
if (isDisposingRef.current) return;
|
|
||||||
try {
|
try {
|
||||||
if (event.tile instanceof VectorTile) {
|
if (event.tile instanceof VectorTile) {
|
||||||
const renderFeatures = event.tile.getFeatures();
|
const renderFeatures = event.tile.getFeatures();
|
||||||
const data = new Map();
|
const data = new Map();
|
||||||
|
|
||||||
renderFeatures.forEach((renderFeature: any) => {
|
renderFeatures.forEach((renderFeature) => {
|
||||||
try {
|
try {
|
||||||
const props = renderFeature.getProperties();
|
const props = renderFeature.getProperties();
|
||||||
const featureId = props.id;
|
const featureId = props.id;
|
||||||
@@ -655,15 +582,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const uniqueData = Array.from(data.values());
|
const uniqueData = Array.from(data.values());
|
||||||
if (uniqueData.length > 0) {
|
if (uniqueData.length > 0) {
|
||||||
uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item));
|
uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item));
|
||||||
debouncedUpdateDataRef.current?.();
|
debouncedUpdateData.current();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Pipe tile load error:", error);
|
console.error("Pipe tile load error:", error);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
|
||||||
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
|
||||||
// 监听 junctionsLayer 的 visible 变化
|
// 监听 junctionsLayer 的 visible 变化
|
||||||
const handleJunctionVisibilityChange = () => {
|
const handleJunctionVisibilityChange = () => {
|
||||||
const isVisible = junctionsLayer.getVisible();
|
const isVisible = junctionsLayer.getVisible();
|
||||||
@@ -777,7 +702,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
// 持久化视图(中心点 + 缩放),防抖写入 localStorage
|
// 持久化视图(中心点 + 缩放),防抖写入 localStorage
|
||||||
const persistView = debounce(() => {
|
const persistView = debounce(() => {
|
||||||
if (isDisposingRef.current) return;
|
|
||||||
try {
|
try {
|
||||||
const view = map.getView();
|
const view = map.getView();
|
||||||
const center = view.getCenter();
|
const center = view.getCenter();
|
||||||
@@ -795,7 +719,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
// 监听缩放变化并持久化,同时更新 currentZoom
|
// 监听缩放变化并持久化,同时更新 currentZoom
|
||||||
const handleViewChange = () => {
|
const handleViewChange = () => {
|
||||||
addTimeout(() => {
|
setTimeout(() => {
|
||||||
const zoom = map.getView().getZoom() || 0;
|
const zoom = map.getView().getZoom() || 0;
|
||||||
setCurrentZoom(zoom);
|
setCurrentZoom(zoom);
|
||||||
persistView();
|
persistView();
|
||||||
@@ -804,7 +728,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
map.getView().on("change", handleViewChange);
|
map.getView().on("change", handleViewChange);
|
||||||
|
|
||||||
// 初始化当前缩放级别并强制触发瓦片加载
|
// 初始化当前缩放级别并强制触发瓦片加载
|
||||||
addTimeout(() => {
|
setTimeout(() => {
|
||||||
const initialZoom = map.getView().getZoom() || 11;
|
const initialZoom = map.getView().getZoom() || 11;
|
||||||
setCurrentZoom(initialZoom);
|
setCurrentZoom(initialZoom);
|
||||||
// 强制触发地图渲染,让瓦片加载事件触发
|
// 强制触发地图渲染,让瓦片加载事件触发
|
||||||
@@ -818,11 +742,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
latitude: 0,
|
latitude: 0,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
},
|
},
|
||||||
canvas: canvasRef.current,
|
canvas: "deck-canvas",
|
||||||
controller: false, // 由 OpenLayers 控制视图
|
controller: false, // 由 OpenLayers 控制视图
|
||||||
layers: [],
|
layers: [],
|
||||||
});
|
});
|
||||||
const deckLayer = new DeckLayer(deck, canvasRef.current, {
|
const deckLayer = new DeckLayer(deck, {
|
||||||
name: "deckLayer",
|
name: "deckLayer",
|
||||||
value: "deckLayer",
|
value: "deckLayer",
|
||||||
});
|
});
|
||||||
@@ -832,37 +756,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
return () => {
|
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);
|
junctionsLayer.un("change:visible", handleJunctionVisibilityChange);
|
||||||
pipesLayer.un("change:visible", handlePipeVisibilityChange);
|
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.setTarget(undefined);
|
||||||
map.dispose();
|
map.dispose();
|
||||||
|
deck.finalize();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, []);
|
||||||
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
|
||||||
|
|
||||||
// 当数据变化时,更新 deck.gl 图层
|
// 当数据变化时,更新 deck.gl 图层
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDisposingRef.current) return;
|
|
||||||
const deckLayer = deckLayerRef.current;
|
const deckLayer = deckLayerRef.current;
|
||||||
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
|
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
|
||||||
if (deckLayer.isDisposedLayer()) return;
|
|
||||||
if (!mergedJunctionData.length) return;
|
if (!mergedJunctionData.length) return;
|
||||||
if (!mergedPipeData.length) return;
|
if (!mergedPipeData.length) return;
|
||||||
const junctionTextLayer = new TextLayer({
|
const junctionTextLayer = new TextLayer({
|
||||||
@@ -877,7 +782,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
let propPart = "";
|
let propPart = "";
|
||||||
if (showJunctionTextLayer && d[junctionText] !== undefined) {
|
if (showJunctionTextLayer && d[junctionText] !== undefined) {
|
||||||
const value = (d[junctionText] as number).toFixed(3);
|
const value = (d[junctionText] as number).toFixed(3);
|
||||||
propPart = `${value}`;
|
// 根据属性类型添加符号前缀
|
||||||
|
const prefix =
|
||||||
|
{
|
||||||
|
pressure: "P:",
|
||||||
|
head: "H:",
|
||||||
|
quality: "Q:",
|
||||||
|
actualdemand: "D:",
|
||||||
|
}[junctionText] || "";
|
||||||
|
propPart = `${prefix}${value}`;
|
||||||
}
|
}
|
||||||
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
||||||
return idPart || propPart;
|
return idPart || propPart;
|
||||||
@@ -929,7 +842,17 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
} else {
|
} else {
|
||||||
value = Math.abs(d[pipeText] as number).toFixed(3);
|
value = Math.abs(d[pipeText] as number).toFixed(3);
|
||||||
}
|
}
|
||||||
propPart = `${value}`;
|
// 根据属性类型添加符号前缀
|
||||||
|
const prefix =
|
||||||
|
{
|
||||||
|
flow: "F:",
|
||||||
|
velocity: "V:",
|
||||||
|
headloss: "HL:",
|
||||||
|
unit_headloss: "UHL:",
|
||||||
|
diameter: "D:",
|
||||||
|
friction: "FR:",
|
||||||
|
}[pipeText] || "";
|
||||||
|
propPart = `${prefix}${value}`;
|
||||||
}
|
}
|
||||||
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
||||||
return idPart || propPart;
|
return idPart || propPart;
|
||||||
@@ -1012,7 +935,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
// 控制流动动画开关
|
// 控制流动动画开关
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDisposingRef.current) return;
|
|
||||||
if (pipeText === "flow" && currentPipeCalData.length > 0) {
|
if (pipeText === "flow" && currentPipeCalData.length > 0) {
|
||||||
flowAnimation.current = true;
|
flowAnimation.current = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -1025,7 +947,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
// 动画循环
|
// 动画循环
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
if (isDisposingRef.current || deckLayer.isDisposedLayer()) return;
|
|
||||||
// 动画总时长(秒)
|
// 动画总时长(秒)
|
||||||
const animationDuration = 10;
|
const animationDuration = 10;
|
||||||
const bufferTime = 2;
|
const bufferTime = 2;
|
||||||
@@ -1125,7 +1046,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
<MapTools />
|
<MapTools />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<canvas ref={canvasRef} />
|
<canvas id="deck-canvas" />
|
||||||
</MapContext.Provider>
|
</MapContext.Provider>
|
||||||
</DataContext.Provider>
|
</DataContext.Provider>
|
||||||
</>
|
</>
|
||||||
+11
-69
@@ -8,24 +8,19 @@ import {
|
|||||||
} from "@refinedev/mui";
|
} from "@refinedev/mui";
|
||||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
import routerProvider from "@refinedev/nextjs-router";
|
||||||
|
|
||||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
|
||||||
import { useAuthStore } from "@/store/authStore";
|
|
||||||
|
|
||||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
import { TbDatabaseEdit } from "react-icons/tb";
|
||||||
import { LuReplace } from "react-icons/lu";
|
import { LuReplace } from "react-icons/lu";
|
||||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
import { TbLocationPin } from "react-icons/tb";
|
||||||
import {
|
import { AiOutlinePartition } from "react-icons/ai";
|
||||||
MyLocation as MyLocationIcon,
|
|
||||||
Search as SearchIcon,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
|
|
||||||
type RefineContextProps = {
|
type RefineContextProps = {
|
||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
@@ -36,9 +31,7 @@ export const RefineContext = (
|
|||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ProjectProvider>
|
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
</ProjectProvider>
|
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -50,11 +43,6 @@ type AppProps = {
|
|||||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||||
const { data, status } = useSession();
|
const { data, status } = useSession();
|
||||||
const to = usePathname();
|
const to = usePathname();
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
|
||||||
}, [data?.accessToken, setAccessToken]);
|
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <span>loading...</span>;
|
return <span>loading...</span>;
|
||||||
@@ -111,7 +99,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
if (data?.user) {
|
if (data?.user) {
|
||||||
const { user } = data;
|
const { user } = data;
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
name: user.name,
|
||||||
avatar: user.image,
|
avatar: user.image,
|
||||||
};
|
};
|
||||||
@@ -167,64 +154,19 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hydraulic Simulation",
|
name: "风险分析定位",
|
||||||
|
list: "/risk-analysis-location",
|
||||||
meta: {
|
meta: {
|
||||||
icon: <MdWater className="w-6 h-6" />,
|
|
||||||
label: "事件模拟",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "爆管模拟",
|
|
||||||
list: "/hydraulic-simulation/burst-simulation",
|
|
||||||
meta: {
|
|
||||||
parent: "Hydraulic Simulation",
|
|
||||||
icon: <TbLocationPin className="w-6 h-6" />,
|
icon: <TbLocationPin className="w-6 h-6" />,
|
||||||
label: "爆管模拟",
|
label: "风险分析定位",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "爆管侦测",
|
name: "管网优化分区",
|
||||||
list: "/hydraulic-simulation/burst-detection",
|
list: "/network-partition-optimization",
|
||||||
meta: {
|
meta: {
|
||||||
parent: "Hydraulic Simulation",
|
icon: <AiOutlinePartition className="w-6 h-6" />,
|
||||||
icon: <TbActivity className="w-6 h-6" />,
|
label: "管网优化分区",
|
||||||
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",
|
|
||||||
meta: {
|
|
||||||
parent: "Hydraulic Simulation",
|
|
||||||
icon: <SearchIcon className="w-6 h-6" />,
|
|
||||||
label: "DMA 漏损识别",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "水质模拟",
|
|
||||||
list: "/hydraulic-simulation/contaminant-simulation",
|
|
||||||
meta: {
|
|
||||||
parent: "Hydraulic Simulation",
|
|
||||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
|
||||||
label: "水质模拟",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "管道冲洗",
|
|
||||||
list: "/hydraulic-simulation/flushing-analysis",
|
|
||||||
meta: {
|
|
||||||
parent: "Hydraulic Simulation",
|
|
||||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
|
||||||
label: "管道冲洗",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,58 +1,13 @@
|
|||||||
import { NextAuthOptions } from "next-auth";
|
|
||||||
import { JWT } from "next-auth/jwt";
|
|
||||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
||||||
|
|
||||||
type KeycloakTokenResponse = {
|
const authOptions = {
|
||||||
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
|
// Configure one or more authentication providers
|
||||||
providers: [
|
providers: [
|
||||||
KeycloakProvider({
|
KeycloakProvider({
|
||||||
clientId: keycloakClientId,
|
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
clientSecret: keycloakClientSecret,
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
issuer: keycloakIssuer,
|
issuer: process.env.KEYCLOAK_ISSUER!,
|
||||||
profile(profile) {
|
profile(profile) {
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
@@ -64,45 +19,6 @@ const authOptions: NextAuthOptions = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
callbacks: {
|
|
||||||
jwt: async ({ token, profile, account }) => {
|
|
||||||
if (profile?.sub) {
|
|
||||||
token.sub = profile.sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
session.user.id = token.sub;
|
|
||||||
}
|
|
||||||
if (token.accessToken) {
|
|
||||||
session.accessToken = token.accessToken;
|
|
||||||
}
|
|
||||||
if (token.error) {
|
|
||||||
session.error = token.error;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default authOptions;
|
export default authOptions;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Container from "@mui/material/Container";
|
import Container from "@mui/material/Container";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useLogin } from "@refinedev/core";
|
import { useLogin } from "@refinedev/core";
|
||||||
import { Title } from "@components/title";
|
import { ThemedTitle } from "@refinedev/mui";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { mutate: login } = useLogin();
|
const { mutate: login } = useLogin();
|
||||||
@@ -26,9 +25,13 @@ export default function Login() {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
>
|
>
|
||||||
<Box display="flex" justifyContent="center">
|
<ThemedTitle
|
||||||
<Title collapsed={false} />
|
collapsed={false}
|
||||||
</Box>
|
wrapperStyles={{
|
||||||
|
fontSize: "22px",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
style={{ width: "240px" }}
|
style={{ width: "240px" }}
|
||||||
size="large"
|
size="large"
|
||||||
@@ -39,12 +42,10 @@ export default function Login() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
||||||
Powered by
|
Powered by
|
||||||
<Image
|
<img
|
||||||
style={{ padding: "0 5px" }}
|
style={{ padding: "0 5px" }}
|
||||||
alt="Keycloak"
|
alt="Keycloak"
|
||||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
/>
|
/>
|
||||||
Keycloak
|
Keycloak
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
"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 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_features: {
|
|
||||||
label: "定位要素",
|
|
||||||
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
||||||
actionLabel: "定位到地图",
|
|
||||||
color: "#5470c6",
|
|
||||||
},
|
|
||||||
locate_junctions: {
|
|
||||||
label: "定位节点",
|
|
||||||
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
||||||
actionLabel: "定位到地图",
|
|
||||||
color: "#5470c6",
|
|
||||||
},
|
|
||||||
locate_pipes: {
|
|
||||||
label: "定位管道",
|
|
||||||
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
||||||
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 }} />,
|
|
||||||
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 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)) {
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
const resolveLocateFeatureType = (): string => {
|
|
||||||
const rawType = params.feature_type;
|
|
||||||
if (typeof rawType === "string" && rawType.trim()) {
|
|
||||||
return rawType.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
switch (toolCall.tool) {
|
|
||||||
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": {
|
|
||||||
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 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)) {
|
|
||||||
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_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_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_features",
|
|
||||||
ids: normalizeIds(),
|
|
||||||
layer,
|
|
||||||
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
"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";
|
|
||||||
@@ -1,943 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
|
|
||||||
// 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 { streamCopilotChat } 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 { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
|
||||||
|
|
||||||
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 [isResizing, setIsResizing] = useState(false);
|
|
||||||
const [conversationId, setConversationId] = useState<string | undefined>(
|
|
||||||
initialChatStateRef.current.conversationId
|
|
||||||
);
|
|
||||||
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,
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Auto-scroll
|
|
||||||
useEffect(() => {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, [messages, isStreaming]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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 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 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);
|
|
||||||
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) =>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[conversationId, isStreaming, stopListening, dispatchToolAction],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
const prompt = input.trim();
|
|
||||||
if (!prompt || isStreaming) return;
|
|
||||||
await sendPrompt(prompt);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAbort = () => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleHeaderMenuClose = useCallback(() => {
|
|
||||||
setHeaderMenuAnchorEl(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNewConversation = useCallback(() => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
handleStopSpeech();
|
|
||||||
stopListening();
|
|
||||||
setMessages([]);
|
|
||||||
setConversationId(undefined);
|
|
||||||
setInput("");
|
|
||||||
setIsStreaming(false);
|
|
||||||
handleHeaderMenuClose();
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
}, [handleHeaderMenuClose, handleStopSpeech, stopListening]);
|
|
||||||
|
|
||||||
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) => (
|
|
||||||
<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"
|
|
||||||
variant="persistent"
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
hideBackdrop
|
|
||||||
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }}
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
width: { xs: "100%", sm: width },
|
|
||||||
background: "transparent",
|
|
||||||
boxShadow: "none",
|
|
||||||
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
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
bgcolor: alpha("#fff", 0.75), // Light glass base
|
|
||||||
backdropFilter: "blur(30px)",
|
|
||||||
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} />
|
|
||||||
<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 }}
|
|
||||||
>
|
|
||||||
<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 }}>
|
|
||||||
Copilot
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
|
||||||
你的 AI 助手
|
|
||||||
</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" } }}>
|
|
||||||
<CloseRounded />
|
|
||||||
</IconButton>
|
|
||||||
</motion.div>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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="输入消息给 Copilot..."
|
|
||||||
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>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,647 +0,0 @@
|
|||||||
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;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
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 playbackTokenRef = useRef(0);
|
|
||||||
|
|
||||||
const isSupported =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window.FormData !== "undefined" &&
|
|
||||||
(typeof window.AudioContext !== "undefined" ||
|
|
||||||
typeof window.webkitAudioContext !== "undefined");
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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(
|
|
||||||
async (messageId: string, text: string) => {
|
|
||||||
const normalizedText = text.trim();
|
|
||||||
if (!isSupported || !normalizedText) return;
|
|
||||||
|
|
||||||
const playbackToken = playbackTokenRef.current + 1;
|
|
||||||
playbackTokenRef.current = playbackToken;
|
|
||||||
await stopPlayback();
|
|
||||||
|
|
||||||
setSpeakingMessageId(messageId);
|
|
||||||
setSpeechState("playing");
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
buildServiceUrl,
|
|
||||||
clearAudio,
|
|
||||||
closeStream,
|
|
||||||
fetchStreamResult,
|
|
||||||
isSupported,
|
|
||||||
playPcmStream,
|
|
||||||
readErrorMessage,
|
|
||||||
resolveServiceUrl,
|
|
||||||
pollStreamStatus,
|
|
||||||
stopPlayback,
|
|
||||||
stopStatusPolling,
|
|
||||||
withQueryParams,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
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 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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
playbackTokenRef.current += 1;
|
|
||||||
void stopPlayback();
|
|
||||||
};
|
|
||||||
}, [stopPlayback]);
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
.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 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;
|
|
||||||
border-left: 3px solid var(--chat-md-quote-border);
|
|
||||||
background: var(--chat-md-quote-bg);
|
|
||||||
color: var(--chat-md-quote-text);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import {
|
|
||||||
parseAssistantMessageSections,
|
|
||||||
parseContentWithToolCalls,
|
|
||||||
} 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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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_junctions","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
|
|
||||||
const result = parseContentWithToolCalls(content);
|
|
||||||
|
|
||||||
expect(result.toolCalls).toHaveLength(1);
|
|
||||||
expect(result.toolCalls[0].tool).toBe("locate_junctions");
|
|
||||||
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_junctions" },
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
export type AssistantMessageSections = {
|
|
||||||
answer: string;
|
|
||||||
thought: string | null;
|
|
||||||
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>";
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* 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 };
|
|
||||||
};
|
|
||||||
@@ -3,82 +3,29 @@
|
|||||||
import { ColorModeContext } from "@contexts/color-mode";
|
import { ColorModeContext } from "@contexts/color-mode";
|
||||||
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
||||||
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
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";
|
|
||||||
import AppBar from "@mui/material/AppBar";
|
import AppBar from "@mui/material/AppBar";
|
||||||
import Avatar from "@mui/material/Avatar";
|
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 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 Stack from "@mui/material/Stack";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useGetIdentity, useLogout } from "@refinedev/core";
|
import { useGetIdentity } from "@refinedev/core";
|
||||||
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
|
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
|
||||||
import React, { useContext, useState } from "react";
|
import React, { useContext } 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";
|
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id?: string;
|
id: number;
|
||||||
name?: string;
|
name: string;
|
||||||
avatar?: string;
|
avatar: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||||
sticky = true,
|
sticky = true,
|
||||||
}) => {
|
}) => {
|
||||||
const { mode, setMode } = useContext(ColorModeContext);
|
const { mode, setMode } = useContext(ColorModeContext);
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
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 = (
|
|
||||||
projectId: string,
|
|
||||||
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`);
|
|
||||||
setCurrentProjectId(projectId || networkName || workspace);
|
|
||||||
setShowProjectSelector(false);
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position={sticky ? "sticky" : "relative"}>
|
<AppBar position={sticky ? "sticky" : "relative"}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
@@ -95,56 +42,19 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
{/* <IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode();
|
setMode();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||||
</IconButton> */}
|
</IconButton>
|
||||||
|
|
||||||
{(user?.avatar || user?.name) && (
|
{(user?.avatar || user?.name) && (
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
onClick={() => setShowChatbox(true)}
|
|
||||||
sx={{ mr: 1 }}
|
|
||||||
>
|
|
||||||
<IoChatbubbleEllipsesOutline />
|
|
||||||
</IconButton>
|
|
||||||
<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
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
gap="12px"
|
gap="16px"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
@@ -155,80 +65,15 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
xs: "none",
|
xs: "none",
|
||||||
sm: "inline-block",
|
sm: "inline-block",
|
||||||
},
|
},
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
}}
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
>
|
>
|
||||||
{user?.name}
|
{user?.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Avatar
|
<Avatar src={user?.avatar} alt={user?.name} />
|
||||||
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>
|
</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)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<GlobalChatbox
|
|
||||||
open={showChatbox}
|
|
||||||
onClose={() => setShowChatbox(false)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, Skeleton, CircularProgress } from "@mui/material";
|
import { Box, Skeleton } from "@mui/material";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 地图页面骨架屏组件
|
* 地图页面骨架屏组件
|
||||||
@@ -26,24 +26,7 @@ export function MapSkeleton() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 中央加载指示器 */}
|
{/* 左侧工具栏骨架 */}
|
||||||
<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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -51,100 +34,100 @@ export function MapSkeleton() {
|
|||||||
left: 20,
|
left: 20,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1.5,
|
gap: 1,
|
||||||
zIndex: 5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={i}
|
key={i}
|
||||||
variant="circular"
|
variant="rectangular"
|
||||||
width={40}
|
width={48}
|
||||||
height={40}
|
height={48}
|
||||||
animation="wave"
|
animation="wave"
|
||||||
sx={{ boxShadow: 1 }}
|
sx={{ borderRadius: 1 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 右侧控制面板骨架 (抽屉式) */}
|
{/* 右侧控制面板骨架 */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 20,
|
||||||
right: 0,
|
right: 20,
|
||||||
width: { xs: "100%", sm: 360 },
|
width: 320,
|
||||||
height: "100%",
|
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
borderLeft: 1,
|
borderRadius: 2,
|
||||||
borderColor: "divider",
|
p: 2,
|
||||||
p: 3,
|
boxShadow: 3,
|
||||||
zIndex: 5,
|
|
||||||
display: { xs: "none", md: "flex" },
|
|
||||||
flexDirection: "column",
|
|
||||||
boxShadow: -2,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Skeleton variant="text" width="60%" height={40} sx={{ mb: 3 }} />
|
<Skeleton width="60%" height={32} animation="wave" sx={{ mb: 2 }} />
|
||||||
|
<Skeleton width="100%" height={24} animation="wave" sx={{ mb: 1 }} />
|
||||||
{/* 面板内容区块 */}
|
<Skeleton width="80%" height={24} animation="wave" sx={{ mb: 1 }} />
|
||||||
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
<Skeleton width="90%" height={24} animation="wave" sx={{ mb: 2 }} />
|
||||||
<Skeleton variant="rectangular" width="100%" height={100} sx={{ mb: 2, borderRadius: 1 }} />
|
<Skeleton
|
||||||
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 1 }} />
|
variant="rectangular"
|
||||||
<Skeleton variant="rectangular" width="100%" height={180} sx={{ mb: 2, borderRadius: 1 }} />
|
width="100%"
|
||||||
|
height={200}
|
||||||
<Box sx={{ mt: 2 }}>
|
animation="wave"
|
||||||
{[1, 2, 3].map((i) => (
|
sx={{ borderRadius: 1 }}
|
||||||
<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>
|
||||||
|
|
||||||
{/* 底部时间轴/控制条骨架 */}
|
{/* 底部时间轴骨架 */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 30,
|
bottom: 20,
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
width: { xs: "90%", md: "60%" },
|
width: "60%",
|
||||||
height: 64,
|
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
borderRadius: 4,
|
borderRadius: 2,
|
||||||
boxShadow: 3,
|
|
||||||
p: 2,
|
p: 2,
|
||||||
display: "flex",
|
boxShadow: 3,
|
||||||
alignItems: "center",
|
|
||||||
gap: 2,
|
|
||||||
zIndex: 5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Skeleton variant="circular" width={32} height={32} />
|
<Skeleton width="100%" height={40} animation="wave" />
|
||||||
<Skeleton variant="rectangular" width="100%" height={8} sx={{ borderRadius: 4 }} />
|
|
||||||
<Skeleton variant="text" width={40} />
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 缩放控制骨架 (右下) */}
|
{/* 缩放控制骨架 */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 110,
|
bottom: 100,
|
||||||
right: { xs: 20, md: 380 }, // Adjust if drawer is open
|
right: 20,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
zIndex: 4,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
<Skeleton
|
||||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
variant="rectangular"
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
animation="wave"
|
||||||
|
sx={{ borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
variant="rectangular"
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
animation="wave"
|
||||||
|
sx={{ borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 比例尺骨架 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton width={120} height={24} animation="wave" />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,471 +0,0 @@
|
|||||||
"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-progress",
|
|
||||||
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-success",
|
|
||||||
type: "success",
|
|
||||||
message: "爆管侦测完成",
|
|
||||||
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
open?.({
|
|
||||||
key: "burst-detection-analysis-error",
|
|
||||||
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;
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"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, BurstDetectionSchemeRecord } 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 [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
|
|
||||||
|
|
||||||
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} schemes={schemes} onSchemesChange={setSchemes} />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel value={tab} index={2}>
|
|
||||||
<DetectionResults result={result} />
|
|
||||||
</TabPanel>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BurstDetectionPanel;
|
|
||||||
@@ -1,610 +0,0 @@
|
|||||||
"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;
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
"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;
|
|
||||||
schemes?: BurstDetectionSchemeRecord[];
|
|
||||||
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [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">,
|
|
||||||
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 });
|
|
||||||
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
|
|
||||||
setSchemes(nextSchemes);
|
|
||||||
open?.({
|
|
||||||
type: "success",
|
|
||||||
message: "查询成功",
|
|
||||||
description: `共找到 ${nextSchemes.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;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } 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 { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
|
||||||
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 [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";
|
|
||||||
|
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (dataSource === "simulation" && !selectedSchemeId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return burstStartTime.isBefore(burstEndTime);
|
|
||||||
}, [
|
|
||||||
burstLeakage,
|
|
||||||
burstStartTime,
|
|
||||||
burstEndTime,
|
|
||||||
dataSource,
|
|
||||||
selectedSchemeId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRun = async () => {
|
|
||||||
if (!isValid || !burstStartTime || !burstEndTime) {
|
|
||||||
open?.({ type: "error", message: "请完善参数并确认时间范围合法" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRunning(true);
|
|
||||||
open?.({
|
|
||||||
key: "burst-location-analysis-progress",
|
|
||||||
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, FLOW_DISPLAY_UNIT),
|
|
||||||
min_dpressure: minDpressure,
|
|
||||||
basic_pressure: basicPressure,
|
|
||||||
scada_burst_start: burstStartTime.toISOString(),
|
|
||||||
scada_burst_end: burstEndTime.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-success",
|
|
||||||
type: "success",
|
|
||||||
message: "爆管定位成功",
|
|
||||||
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
open?.({
|
|
||||||
key: "burst-location-analysis-error",
|
|
||||||
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={(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>
|
|
||||||
|
|
||||||
{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={burstStartTime}
|
|
||||||
onChange={setBurstStartTime}
|
|
||||||
maxDateTime={burstEndTime ?? 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={burstEndTime}
|
|
||||||
onChange={setBurstEndTime}
|
|
||||||
minDateTime={burstStartTime ?? undefined}
|
|
||||||
disabled={isSimulationMode}
|
|
||||||
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">
|
|
||||||
爆管漏损流量 ({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;
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"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, BurstSchemeRecord } 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 [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]);
|
|
||||||
|
|
||||||
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} schemes={schemes} onSchemesChange={setSchemes} />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel value={tab} index={2}>
|
|
||||||
<LocationResults result={result} />
|
|
||||||
</TabPanel>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BurstLocationPanel;
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Chip,
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Button,
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
FormatListBulleted,
|
|
||||||
LocationOn as LocationOnIcon,
|
|
||||||
Map as MapIcon,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
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 { 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";
|
|
||||||
|
|
||||||
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 highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
|
||||||
|
|
||||||
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 }, ...base];
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}, [result]);
|
|
||||||
|
|
||||||
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)));
|
|
||||||
})();
|
|
||||||
|
|
||||||
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);
|
|
||||||
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[]) => {
|
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
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-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">
|
|
||||||
<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={`${toM3h(result.burst_leakage, "m³/s").toFixed(2)} ${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>
|
|
||||||
</Box>
|
|
||||||
<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>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LocationResults;
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
"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,
|
|
||||||
BurstLocationSchemeDetail,
|
|
||||||
BurstSchemeRecord,
|
|
||||||
} from "./types";
|
|
||||||
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, schemes: externalSchemes, onSchemesChange }) => {
|
|
||||||
const { open } = useNotification();
|
|
||||||
const [queryAll, setQueryAll] = useState(true);
|
|
||||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
|
||||||
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">,
|
|
||||||
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 {
|
|
||||||
// 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 });
|
|
||||||
const nextSchemes = response.data as BurstSchemeRecord[];
|
|
||||||
setSchemes(nextSchemes);
|
|
||||||
open?.({
|
|
||||||
type: "success",
|
|
||||||
message: "查询成功",
|
|
||||||
description: `共找到 ${nextSchemes.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 } },
|
|
||||||
);
|
|
||||||
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: "方案加载成功",
|
|
||||||
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) => {
|
|
||||||
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>
|
|
||||||
</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">
|
|
||||||
{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" ? `${toM3h(leakage, "m³/s")} ${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>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SchemeQuery;
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
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?: BurstLocationSchemeDetail;
|
|
||||||
}
|
|
||||||
+42
-42
@@ -16,14 +16,14 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
import "dayjs/locale/zh-cn"; // 引入中文包
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@app/OlMap/MapComponent";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import { Style, Stroke, Icon } from "ol/style";
|
import { Style, Stroke, Icon } from "ol/style";
|
||||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
import Feature, { FeatureLike } from "ol/Feature";
|
import Feature, { FeatureLike } from "ol/Feature";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { along, lineString, length, toMercator } from "@turf/turf";
|
import { along, lineString, length, toMercator } from "@turf/turf";
|
||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
@@ -61,39 +61,6 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
duration > 0 &&
|
duration > 0 &&
|
||||||
schemeName.trim() !== "";
|
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(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -170,7 +137,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
map.removeLayer(highlightLayer);
|
map.removeLayer(highlightLayer);
|
||||||
map.un("click", handleMapClickSelectFeatures);
|
map.un("click", handleMapClickSelectFeatures);
|
||||||
};
|
};
|
||||||
}, [map, handleMapClickSelectFeatures]);
|
}, [map]);
|
||||||
// 高亮要素的函数
|
// 高亮要素的函数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightLayer) {
|
if (!highlightLayer) {
|
||||||
@@ -188,7 +155,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [highlightFeatures, highlightLayer]);
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
// 同步高亮要素和爆管点信息
|
// 同步高亮要素和爆管点信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -218,6 +185,42 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [highlightFeatures]);
|
}, [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 = () => {
|
const handleStartSelection = () => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -280,11 +283,8 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, {
|
await axios.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, {
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
|
||||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
// 更新弹窗为成功状态
|
// 更新弹窗为成功状态
|
||||||
open?.({
|
open?.({
|
||||||
@@ -381,7 +381,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
key={pipe.id}
|
key={pipe.id}
|
||||||
className="flex items-center gap-2 p-2 bg-gray-50 rounded"
|
className="flex items-center gap-2 p-2 bg-gray-50 rounded"
|
||||||
>
|
>
|
||||||
<Typography className="flex-shrink-0 text-sm pl-1">
|
<Typography className="flex-shrink-0 text-sm">
|
||||||
{pipe.id}
|
{pipe.id}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className="flex-shrink-0 text-sm text-gray-600">
|
<Typography className="flex-shrink-0 text-sm text-gray-600">
|
||||||
+84
-5
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -22,9 +22,13 @@ import AnalysisParameters from "./AnalysisParameters";
|
|||||||
import SchemeQuery from "./SchemeQuery";
|
import SchemeQuery from "./SchemeQuery";
|
||||||
import LocationResults from "./LocationResults";
|
import LocationResults from "./LocationResults";
|
||||||
import ValveIsolation from "./ValveIsolation";
|
import ValveIsolation from "./ValveIsolation";
|
||||||
import { api } from "@/lib/api";
|
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 { config } from "@config/config";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
|
import { useData } from "@app/OlMap/MapComponent";
|
||||||
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
|
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
@@ -52,17 +56,29 @@ interface BurstPipeAnalysisPanelProps {
|
|||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PanelMode = "burst" | "contaminant";
|
||||||
|
|
||||||
const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||||
open: controlledOpen,
|
open: controlledOpen,
|
||||||
onToggle,
|
onToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const [internalOpen, setInternalOpen] = useState(true);
|
const [internalOpen, setInternalOpen] = useState(true);
|
||||||
const [currentTab, setCurrentTab] = useState(0);
|
const [currentTab, setCurrentTab] = useState(0);
|
||||||
|
const [panelMode, setPanelMode] = useState<PanelMode>("burst");
|
||||||
|
const previousMapText = useRef<{ junction?: string; pipe?: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = useData();
|
||||||
|
|
||||||
// 持久化方案查询结果
|
// 持久化方案查询结果
|
||||||
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||||
// 定位结果数据
|
// 定位结果数据
|
||||||
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
|
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
|
||||||
|
// 选中的管段ID数组
|
||||||
|
const [selectedPipeIds, setSelectedPipeIds] = useState<string[]>([]);
|
||||||
|
// 关阀分析状态提升到父组件
|
||||||
|
const [valveAnalysisTriggered, setValveAnalysisTriggered] = useState(false);
|
||||||
// 关阀分析结果和加载状态
|
// 关阀分析结果和加载状态
|
||||||
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
||||||
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
||||||
@@ -83,9 +99,19 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
setCurrentTab(newValue);
|
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) => {
|
const handleLocateScheme = async (scheme: SchemeRecord) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await axios.get(
|
||||||
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
|
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
|
||||||
);
|
);
|
||||||
setLocationResults(response.data);
|
setLocationResults(response.data);
|
||||||
@@ -100,8 +126,15 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAnalyzePipe = (pipeIds: string[]) => {
|
||||||
|
setSelectedPipeIds(pipeIds);
|
||||||
|
setValveAnalysisTriggered(true);
|
||||||
|
setCurrentTab(3);
|
||||||
|
};
|
||||||
|
|
||||||
const drawerWidth = 520;
|
const drawerWidth = 520;
|
||||||
const panelTitle = "爆管分析";
|
const isBurstMode = panelMode === "burst";
|
||||||
|
const panelTitle = isBurstMode ? "爆管分析" : "水质模拟";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -177,6 +210,32 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Tabs 导航 */}
|
||||||
|
<Box className="border-b border-gray-200 bg-white">
|
||||||
|
<Tabs
|
||||||
|
value={panelMode}
|
||||||
|
onChange={handleModeChange}
|
||||||
|
variant="fullWidth"
|
||||||
|
sx={{
|
||||||
|
minHeight: 46,
|
||||||
|
"& .MuiTab-root": {
|
||||||
|
minHeight: 46,
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
"& .Mui-selected": {
|
||||||
|
color: "#257DD4",
|
||||||
|
},
|
||||||
|
"& .MuiTabs-indicator": {
|
||||||
|
backgroundColor: "#257DD4",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab value="burst" label="爆管分析" />
|
||||||
|
<Tab value="contaminant" label="水质模拟" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
<Box className="border-b border-gray-200 bg-white">
|
<Box className="border-b border-gray-200 bg-white">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={currentTab}
|
value={currentTab}
|
||||||
@@ -212,43 +271,63 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
<Tab
|
<Tab
|
||||||
icon={<MyLocationIcon fontSize="small" />}
|
icon={<MyLocationIcon fontSize="small" />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
label="定位结果"
|
label={isBurstMode ? "定位结果" : "模拟结果"}
|
||||||
/>
|
/>
|
||||||
|
{isBurstMode && (
|
||||||
<Tab
|
<Tab
|
||||||
icon={<HandymanIcon fontSize="small" />}
|
icon={<HandymanIcon fontSize="small" />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
label="关阀分析"
|
label="关阀分析"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Tab 内容 */}
|
{/* Tab 内容 */}
|
||||||
<TabPanel value={currentTab} index={0}>
|
<TabPanel value={currentTab} index={0}>
|
||||||
|
{isBurstMode ? (
|
||||||
<AnalysisParameters />
|
<AnalysisParameters />
|
||||||
|
) : (
|
||||||
|
<ContaminantAnalysisParameters />
|
||||||
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={1}>
|
<TabPanel value={currentTab} index={1}>
|
||||||
|
{isBurstMode ? (
|
||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
onLocate={handleLocateScheme}
|
onLocate={handleLocateScheme}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ContaminantSchemeQuery onViewResults={() => setCurrentTab(2)} />
|
||||||
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={2}>
|
<TabPanel value={currentTab} index={2}>
|
||||||
|
{isBurstMode ? (
|
||||||
<LocationResults
|
<LocationResults
|
||||||
results={locationResults}
|
results={locationResults}
|
||||||
|
onAnalyze={handleAnalyzePipe}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ContaminantResultsPanel schemeName={data?.schemeName} />
|
||||||
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
{isBurstMode && (
|
||||||
<TabPanel value={currentTab} index={3}>
|
<TabPanel value={currentTab} index={3}>
|
||||||
<ValveIsolation
|
<ValveIsolation
|
||||||
|
initialPipeIds={selectedPipeIds}
|
||||||
|
shouldFetch={valveAnalysisTriggered}
|
||||||
|
onFetchComplete={() => setValveAnalysisTriggered(false)}
|
||||||
loading={valveAnalysisLoading}
|
loading={valveAnalysisLoading}
|
||||||
result={valveAnalysisResult}
|
result={valveAnalysisResult}
|
||||||
onLoadingChange={setValveAnalysisLoading}
|
onLoadingChange={setValveAnalysisLoading}
|
||||||
onResultChange={setValveAnalysisResult}
|
onResultChange={setValveAnalysisResult}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
+50
-9
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
LocationOn as LocationIcon,
|
LocationOn as LocationIcon,
|
||||||
|
Handyman as HandymanIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@app/OlMap/MapComponent";
|
||||||
import { GeoJSON } from "ol/format";
|
import { GeoJSON } from "ol/format";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
@@ -32,16 +33,18 @@ import { toLonLat } from "ol/proj";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import "moment-timezone";
|
import "moment-timezone";
|
||||||
import { LocationResult } from "./types";
|
import { LocationResult } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
|
||||||
|
|
||||||
interface LocationResultsProps {
|
interface LocationResultsProps {
|
||||||
results?: LocationResult[];
|
results?: LocationResult[];
|
||||||
|
onAnalyze?: (pipeIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LocationResults: React.FC<LocationResultsProps> = ({
|
const LocationResults: React.FC<LocationResultsProps> = ({
|
||||||
results = [],
|
results = [],
|
||||||
|
onAnalyze,
|
||||||
}) => {
|
}) => {
|
||||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
const [highlightLayer, setHighlightLayer] =
|
||||||
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
@@ -144,17 +147,19 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.addLayer(highlightLayer);
|
map.addLayer(highlightLayer);
|
||||||
highlightLayerRef.current = highlightLayer;
|
setHighlightLayer(highlightLayer);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
highlightLayerRef.current = null;
|
|
||||||
map.removeLayer(highlightLayer);
|
map.removeLayer(highlightLayer);
|
||||||
};
|
};
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
// 高亮要素的函数
|
// 高亮要素的函数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const source = highlightLayerRef.current?.getSource();
|
if (!highlightLayer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = highlightLayer.getSource();
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -166,7 +171,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [highlightFeatures]);
|
}, [highlightFeatures, highlightLayer]);
|
||||||
|
|
||||||
// 取第一条记录或空对象
|
// 取第一条记录或空对象
|
||||||
const result = results.length > 0 ? results[0] : null;
|
const result = results.length > 0 ? results[0] : null;
|
||||||
@@ -304,7 +309,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
sx={{ fontSize: "0.875rem" }}
|
sx={{ fontSize: "0.875rem" }}
|
||||||
>
|
>
|
||||||
{result.leakage !== null
|
{result.leakage !== null
|
||||||
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
|
? `${result.leakage.toFixed(2)} m³/h`
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -344,6 +349,23 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
管段列表
|
管段列表
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
|
{onAnalyze && (
|
||||||
|
<Tooltip title="关阀分析">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onAnalyze(result.locate_result!)}
|
||||||
|
color="secondary"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "rgba(156, 39, 176, 0.1)",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(156, 39, 176, 0.2)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HandymanIcon sx={{ fontSize: "1.2rem" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip title="定位所有管道">
|
<Tooltip title="定位所有管道">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -382,6 +404,25 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
{pipeId}
|
{pipeId}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box className="flex items-center gap-1">
|
<Box className="flex items-center gap-1">
|
||||||
|
{onAnalyze && (
|
||||||
|
<Tooltip title="单管段关阀分析">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAnalyze([pipeId]);
|
||||||
|
}}
|
||||||
|
className="text-blue-400 hover:text-blue-600"
|
||||||
|
sx={{
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(37, 125, 212, 0.1)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HandymanIcon sx={{ fontSize: "1rem" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{/* <Tooltip title="定位管段">
|
{/* <Tooltip title="定位管段">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
+9
-8
@@ -26,13 +26,13 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
import "dayjs/locale/zh-cn"; // 引入中文包
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { config, NETWORK_NAME } from "@config/config";
|
import { config, NETWORK_NAME } from "@config/config";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
import { useData, useMap } from "@app/OlMap/MapComponent";
|
||||||
import { GeoJSON } from "ol/format";
|
import { GeoJSON } from "ol/format";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
} from "@turf/turf";
|
} from "@turf/turf";
|
||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@app/OlMap/Controls/Timeline";
|
||||||
import { SchemaItem, SchemeRecord } from "./types";
|
import { SchemaItem, SchemeRecord } from "./types";
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
@@ -109,7 +109,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await axios.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
||||||
);
|
);
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
@@ -122,7 +122,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
setSchemes(
|
||||||
|
filteredResults.map((item: SchemaItem) => ({
|
||||||
id: item.scheme_id,
|
id: item.scheme_id,
|
||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
type: item.scheme_type,
|
type: item.scheme_type,
|
||||||
@@ -130,8 +131,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
create_time: item.create_time,
|
create_time: item.create_time,
|
||||||
startTime: item.scheme_start_time,
|
startTime: item.scheme_start_time,
|
||||||
schemeDetail: item.scheme_detail,
|
schemeDetail: item.scheme_detail,
|
||||||
}));
|
})),
|
||||||
setSchemes(nextSchemes);
|
);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
@@ -298,7 +299,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [highlightFeatures, highlightLayer]);
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { LocationOn as LocationIcon } from "@mui/icons-material";
|
||||||
|
import axios from "axios";
|
||||||
|
import { config, NETWORK_NAME } from "@config/config";
|
||||||
|
import { ValveIsolationResult } from "./types";
|
||||||
|
import { useNotification } from "@refinedev/core";
|
||||||
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import { useMap } from "@app/OlMap/MapComponent";
|
||||||
|
import { GeoJSON } from "ol/format";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import { Circle as CircleStyle, Fill, Stroke, Style, Icon } from "ol/style";
|
||||||
|
import Feature, { FeatureLike } from "ol/Feature";
|
||||||
|
import {
|
||||||
|
bbox,
|
||||||
|
featureCollection,
|
||||||
|
along,
|
||||||
|
lineString,
|
||||||
|
length,
|
||||||
|
toMercator,
|
||||||
|
} from "@turf/turf";
|
||||||
|
import { Point } from "ol/geom";
|
||||||
|
import { toLonLat } from "ol/proj";
|
||||||
|
|
||||||
|
interface ValveIsolationProps {
|
||||||
|
initialPipeIds?: string[];
|
||||||
|
shouldFetch?: boolean;
|
||||||
|
onFetchComplete?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
result?: ValveIsolationResult | null;
|
||||||
|
onLoadingChange?: (loading: boolean) => void;
|
||||||
|
onResultChange?: (result: ValveIsolationResult | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||||
|
initialPipeIds,
|
||||||
|
shouldFetch = false,
|
||||||
|
onFetchComplete,
|
||||||
|
loading: externalLoading,
|
||||||
|
result: externalResult,
|
||||||
|
onLoadingChange,
|
||||||
|
onResultChange,
|
||||||
|
}) => {
|
||||||
|
const [internalLoading, setInternalLoading] = useState(false);
|
||||||
|
const [internalResult, setInternalResult] =
|
||||||
|
useState<ValveIsolationResult | null>(null);
|
||||||
|
|
||||||
|
// 使用外部状态或内部状态
|
||||||
|
const loading =
|
||||||
|
externalLoading !== undefined ? externalLoading : internalLoading;
|
||||||
|
const result = externalResult !== undefined ? externalResult : internalResult;
|
||||||
|
const setLoading = onLoadingChange || setInternalLoading;
|
||||||
|
const setResult = onResultChange || setInternalResult;
|
||||||
|
const [highlightLayer, setHighlightLayer] =
|
||||||
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
|
const [highlightType, setHighlightType] = useState<
|
||||||
|
"must_close" | "optional" | "affected_node" | "pipe"
|
||||||
|
>("affected_node");
|
||||||
|
const { open } = useNotification();
|
||||||
|
const lastPipeIdsRef = useRef<string>("");
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
const handleLocatePipes = (pipeIds: string[]) => {
|
||||||
|
if (pipeIds.length > 0) {
|
||||||
|
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
|
||||||
|
if (features.length > 0) {
|
||||||
|
// 设置高亮类型为管段
|
||||||
|
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) {
|
||||||
|
map?.getView().fit(extent, { maxZoom: 18, duration: 1000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocateNodes = (nodeIds: string[]) => {
|
||||||
|
if (nodeIds.length > 0) {
|
||||||
|
queryFeaturesByIds(nodeIds, "geo_junctions").then((features) => {
|
||||||
|
if (features.length > 0) {
|
||||||
|
// 设置高亮类型为受影响节点
|
||||||
|
setHighlightType("affected_node");
|
||||||
|
// 设置高亮要素
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocateMustCloseValves = (valveIds: string[]) => {
|
||||||
|
if (valveIds.length > 0) {
|
||||||
|
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
|
||||||
|
if (features.length > 0) {
|
||||||
|
// 设置高亮类型为必关阀门
|
||||||
|
setHighlightType("must_close");
|
||||||
|
// 设置高亮要素
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocateOptionalValves = (valveIds: string[]) => {
|
||||||
|
if (valveIds.length > 0) {
|
||||||
|
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
|
||||||
|
if (features.length > 0) {
|
||||||
|
// 设置高亮类型为可选阀门
|
||||||
|
setHighlightType("optional");
|
||||||
|
// 设置高亮要素
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAnalysis = useCallback(
|
||||||
|
async (ids: string[]) => {
|
||||||
|
if (!ids || ids.length === 0) {
|
||||||
|
open?.({ type: "error", message: "请提供管段ID" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`${config.BACKEND_URL}/api/v1/valve_isolation_analysis/`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
network: NETWORK_NAME,
|
||||||
|
accident_element: ids,
|
||||||
|
},
|
||||||
|
paramsSerializer: {
|
||||||
|
indexes: null, // 生成格式: accident_element=P1&accident_element=P2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setResult(response.data);
|
||||||
|
open?.({ type: "success", message: "分析成功" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "分析失败",
|
||||||
|
description: "无法获取关阀分析结果",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
onFetchComplete?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open, onFetchComplete],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 只有在明确要求获取数据时才调用 API
|
||||||
|
if (shouldFetch && initialPipeIds && initialPipeIds.length > 0) {
|
||||||
|
// 使用排序后的字符串作为唯一标识,避免数组引用变化导致重复调用
|
||||||
|
const pipeIdsKey = [...initialPipeIds].sort().join(",");
|
||||||
|
|
||||||
|
// 只有当 pipeIds 真正改变时才调用 API
|
||||||
|
if (pipeIdsKey !== lastPipeIdsRef.current) {
|
||||||
|
lastPipeIdsRef.current = pipeIdsKey;
|
||||||
|
fetchAnalysis(initialPipeIds);
|
||||||
|
} else {
|
||||||
|
// 如果 pipeIds 相同,直接调用完成回调
|
||||||
|
onFetchComplete?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [shouldFetch, initialPipeIds]);
|
||||||
|
|
||||||
|
// 初始化高亮图层
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
// 动态样式函数,根据 highlightType 返回不同的样式
|
||||||
|
const getHighlightStyle = (feature: FeatureLike) => {
|
||||||
|
if (highlightType === "pipe") {
|
||||||
|
// 管段 - 多层红色线条样式 + 中点图标
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阀门和节点的样式
|
||||||
|
let color: string;
|
||||||
|
let strokeColor: string;
|
||||||
|
let radius: number;
|
||||||
|
|
||||||
|
switch (highlightType) {
|
||||||
|
case "must_close":
|
||||||
|
// 必关阀门 - 深红色
|
||||||
|
color = "rgba(211, 47, 47, 0.6)";
|
||||||
|
strokeColor = "rgba(211, 47, 47, 1)";
|
||||||
|
radius = 10;
|
||||||
|
break;
|
||||||
|
case "optional":
|
||||||
|
// 可选阀门 - 橙色
|
||||||
|
color = "rgba(237, 108, 2, 0.6)";
|
||||||
|
strokeColor = "rgba(237, 108, 2, 1)";
|
||||||
|
radius = 10;
|
||||||
|
break;
|
||||||
|
case "affected_node":
|
||||||
|
default:
|
||||||
|
// 受影响节点 - 蓝色
|
||||||
|
color = "rgba(25, 118, 210, 0.6)";
|
||||||
|
strokeColor = "rgba(25, 118, 210, 1)";
|
||||||
|
radius = 8;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Style({
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: radius,
|
||||||
|
fill: new Fill({
|
||||||
|
color: color,
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: strokeColor,
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建高亮图层
|
||||||
|
const highlightLayer = new VectorLayer({
|
||||||
|
source: new VectorSource(),
|
||||||
|
style: getHighlightStyle,
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 12,
|
||||||
|
properties: {
|
||||||
|
name: "阀门节点高亮",
|
||||||
|
value: "valve_node_highlight",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer(highlightLayer);
|
||||||
|
setHighlightLayer(highlightLayer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.removeLayer(highlightLayer);
|
||||||
|
};
|
||||||
|
}, [map, highlightType]);
|
||||||
|
|
||||||
|
// 高亮要素的函数
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col h-full">
|
||||||
|
{/* Results Section */}
|
||||||
|
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<Box className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||||
|
<CircularProgress size={40} className="mb-4" />
|
||||||
|
<Typography variant="body2">正在分析...</Typography>
|
||||||
|
</Box>
|
||||||
|
) : result ? (
|
||||||
|
<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">
|
||||||
|
关阀分析结果
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={result.isolatable ? "可隔离" : "不可隔离"}
|
||||||
|
size="small"
|
||||||
|
color={result.isolatable ? "success" : "error"}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
height: "24px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box className="bg-gradient-to-r from-red-50 via-pink-50 to-red-50 rounded-lg p-3 border border-red-200 shadow-sm">
|
||||||
|
<Box className="flex items-center justify-between mb-2">
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Box className="w-2 h-2 rounded-full bg-red-600 animate-pulse"></Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
className="text-red-700 font-semibold uppercase tracking-wide"
|
||||||
|
sx={{ fontSize: "0.7rem" }}
|
||||||
|
>
|
||||||
|
爆管管段
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{result.accident_elements &&
|
||||||
|
result.accident_elements.length > 0 && (
|
||||||
|
<Tooltip title="定位所有管段">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
handleLocatePipes(result.accident_elements!)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "rgba(255, 0, 0, 0.1)",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(255, 0, 0, 0.2)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocationIcon
|
||||||
|
sx={{ fontSize: "1rem", color: "rgb(220, 38, 38)" }}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box className="flex flex-wrap gap-2">
|
||||||
|
{result.accident_elements?.map(
|
||||||
|
(pipeId: string, idx: number) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
label={pipeId}
|
||||||
|
size="small"
|
||||||
|
onClick={() => 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)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 主要信息:三栏卡片布局 */}
|
||||||
|
<Box className="grid grid-cols-3 gap-3 mb-5">
|
||||||
|
{/* 必关阀门卡片 */}
|
||||||
|
<Box className="bg-gradient-to-br from-red-50 to-red-100 rounded-lg p-3 border border-red-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-red-600"></Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
className="text-red-700 font-semibold uppercase tracking-wide"
|
||||||
|
sx={{ fontSize: "0.7rem" }}
|
||||||
|
>
|
||||||
|
必关阀门
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
className="font-bold text-red-900"
|
||||||
|
sx={{ fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
{result.must_close_valves?.length || 0} 个
|
||||||
|
</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.optional_valves?.length || 0} 个
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 受影响节点卡片 */}
|
||||||
|
<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"
|
||||||
|
sx={{ fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
{result.affected_nodes?.length || 0} 个
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 必须关闭阀门详细列表 */}
|
||||||
|
{result.must_close_valves &&
|
||||||
|
result.must_close_valves.length > 0 && (
|
||||||
|
<Box className="bg-white rounded-lg p-4 border-2 border-red-200 shadow-sm mb-4">
|
||||||
|
<Box className="flex items-center justify-between mb-3">
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
className="text-gray-900 font-bold"
|
||||||
|
sx={{ fontSize: "0.95rem" }}
|
||||||
|
>
|
||||||
|
必须关闭阀门
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="定位所有阀门">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
handleLocateMustCloseValves(result.must_close_valves!)
|
||||||
|
}
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "rgba(211, 47, 47, 0.1)",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(211, 47, 47, 0.2)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocationIcon sx={{ fontSize: "1.2rem" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box className="grid grid-cols-3 gap-2">
|
||||||
|
{result.must_close_valves.map((valveId, idx) => (
|
||||||
|
<Box
|
||||||
|
key={idx}
|
||||||
|
className="bg-gradient-to-r from-red-50 to-white rounded-lg px-3 py-2 border border-red-200 hover:border-red-400 hover:shadow-md transition-all cursor-pointer group"
|
||||||
|
onClick={() => handleLocateMustCloseValves([valveId])}
|
||||||
|
sx={{
|
||||||
|
"&:active": {
|
||||||
|
transform: "scale(0.98)",
|
||||||
|
boxShadow: "0 1px 2px rgba(211, 47, 47, 0.2)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
className="font-semibold text-red-700 group-hover:text-red-900"
|
||||||
|
>
|
||||||
|
{valveId}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 可选关闭阀门详细列表 */}
|
||||||
|
{result.optional_valves && result.optional_valves.length > 0 && (
|
||||||
|
<Box className="bg-white rounded-lg p-4 border-2 border-orange-200 shadow-sm mb-4">
|
||||||
|
<Box className="flex items-center justify-between mb-3">
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
className="text-gray-900 font-bold"
|
||||||
|
sx={{ fontSize: "0.95rem" }}
|
||||||
|
>
|
||||||
|
可选关闭阀门
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="定位所有阀门">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
handleLocateOptionalValves(result.optional_valves!)
|
||||||
|
}
|
||||||
|
color="warning"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "rgba(237, 108, 2, 0.1)",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(237, 108, 2, 0.2)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocationIcon sx={{ fontSize: "1.2rem" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box className="grid grid-cols-3 gap-2">
|
||||||
|
{result.optional_valves.map((valveId, idx) => (
|
||||||
|
<Box
|
||||||
|
key={idx}
|
||||||
|
className="bg-gradient-to-r from-orange-50 to-white rounded-lg px-3 py-2 border border-orange-200 hover:border-orange-400 hover:shadow-md transition-all cursor-pointer group"
|
||||||
|
onClick={() => handleLocateOptionalValves([valveId])}
|
||||||
|
sx={{
|
||||||
|
"&:active": {
|
||||||
|
transform: "scale(0.98)",
|
||||||
|
boxShadow: "0 1px 2px rgba(237, 108, 2, 0.2)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
className="font-semibold text-orange-700 group-hover:text-orange-900"
|
||||||
|
>
|
||||||
|
{valveId}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 受影响节点详细列表 */}
|
||||||
|
{result.affected_nodes && result.affected_nodes.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>
|
||||||
|
<Tooltip title="定位所有节点">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleLocateNodes(result.affected_nodes!)}
|
||||||
|
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 className="grid grid-cols-3 gap-2">
|
||||||
|
{result.affected_nodes.map((nodeId, 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={() => handleLocateNodes([nodeId])}
|
||||||
|
sx={{
|
||||||
|
"&:active": {
|
||||||
|
transform: "scale(0.98)",
|
||||||
|
boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
className="font-semibold text-blue-700 group-hover:text-blue-900"
|
||||||
|
>
|
||||||
|
{nodeId}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<path d="M40 25 L40 55" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<rect
|
||||||
|
x="30"
|
||||||
|
y="35"
|
||||||
|
width="20"
|
||||||
|
height="10"
|
||||||
|
fill="currentColor"
|
||||||
|
rx="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M25 40 L30 40 M50 40 L55 40"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2">暂无关阀分析结果</Typography>
|
||||||
|
<Typography variant="body2" className="mt-1">
|
||||||
|
请先查看定位结果
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ValveIsolation;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,9 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@app/OlMap/MapComponent";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style";
|
import { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style";
|
||||||
@@ -59,32 +59,6 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}, [network, startTime, sourceNode, concentration, duration, schemeName]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
@@ -132,7 +106,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
map.removeLayer(layer);
|
map.removeLayer(layer);
|
||||||
map.un("click", handleMapClickSelectFeatures);
|
map.un("click", handleMapClickSelectFeatures);
|
||||||
};
|
};
|
||||||
}, [map, handleMapClickSelectFeatures]);
|
}, [map]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightLayer) return;
|
if (!highlightLayer) return;
|
||||||
@@ -144,6 +118,32 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [highlightFeature, highlightLayer]);
|
}, [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 = () => {
|
const handleStartSelection = () => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
setIsSelecting(true);
|
setIsSelecting(true);
|
||||||
@@ -175,10 +175,6 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
? startTime.format("YYYY-MM-DDTHH:mm:00Z")
|
? startTime.format("YYYY-MM-DDTHH:mm:00Z")
|
||||||
: "";
|
: "";
|
||||||
try {
|
try {
|
||||||
if (!pattern) {
|
|
||||||
setPattern("CONSTANT");
|
|
||||||
console.log("默认设置 pattern 为 CONSTANT");
|
|
||||||
}
|
|
||||||
const params = {
|
const params = {
|
||||||
network,
|
network,
|
||||||
start_time: start_time,
|
start_time: start_time,
|
||||||
@@ -189,7 +185,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
|
await axios.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,7 +276,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{sourceNode ? (
|
{sourceNode ? (
|
||||||
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||||
<Typography className="flex-shrink-0 pl-1 text-sm">
|
<Typography className="flex-shrink-0 text-sm">
|
||||||
{sourceNode}
|
{sourceNode}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className="flex-shrink-0 text-sm text-gray-600">
|
<Typography className="flex-shrink-0 text-sm text-gray-600">
|
||||||
@@ -377,7 +373,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
value={pattern}
|
value={pattern}
|
||||||
onChange={(e) => setPattern(e.target.value)}
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
placeholder="可选,输入 pattern 名称,默认为 CONSTANT"
|
placeholder="可选,输入 pattern 名称"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"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;
|
||||||
@@ -25,19 +25,19 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { config, NETWORK_NAME } from "@config/config";
|
import { config, NETWORK_NAME } from "@config/config";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
import { useData, useMap } from "@app/OlMap/MapComponent";
|
||||||
import { GeoJSON } from "ol/format";
|
import { GeoJSON } from "ol/format";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
|
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
|
||||||
import Feature from "ol/Feature";
|
import Feature from "ol/Feature";
|
||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@app/OlMap/Controls/Timeline";
|
||||||
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
|
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
@@ -180,7 +180,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
if (!queryAll && !queryDate) return;
|
if (!queryAll && !queryDate) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await axios.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
||||||
);
|
);
|
||||||
let filteredResults = response.data;
|
let filteredResults = response.data;
|
||||||
@@ -195,7 +195,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSchemes = filteredResults.map((item: ContaminantSchemaItem) => ({
|
setSchemes(
|
||||||
|
filteredResults.map((item: ContaminantSchemaItem) => ({
|
||||||
id: item.scheme_id,
|
id: item.scheme_id,
|
||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
type: item.scheme_type,
|
type: item.scheme_type,
|
||||||
@@ -203,8 +204,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
create_time: item.create_time,
|
create_time: item.create_time,
|
||||||
startTime: item.scheme_start_time,
|
startTime: item.scheme_start_time,
|
||||||
schemeDetail: item.scheme_detail,
|
schemeDetail: item.scheme_detail,
|
||||||
}));
|
})),
|
||||||
setSchemes(nextSchemes);
|
);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
"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 { useData } from "@components/olmap/core/MapComponent";
|
|
||||||
import { ContaminantSchemeRecord } from "./types";
|
|
||||||
|
|
||||||
interface WaterQualityPanelProps {
|
|
||||||
open?: boolean;
|
|
||||||
onToggle?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({
|
|
||||||
open: controlledOpen,
|
|
||||||
onToggle,
|
|
||||||
}) => {
|
|
||||||
const [internalOpen, setInternalOpen] = useState(true);
|
|
||||||
const [currentTab, setCurrentTab] = useState(0);
|
|
||||||
const [schemes, setSchemes] = useState<ContaminantSchemeRecord[]>([]);
|
|
||||||
|
|
||||||
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 && (
|
|
||||||
<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={handleToggle}
|
|
||||||
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={isOpen}
|
|
||||||
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={handleToggle}
|
|
||||||
sx={{ color: "primary.contrastText" }}
|
|
||||||
>
|
|
||||||
<ChevronRight fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="border-b border-gray-200 bg-white">
|
|
||||||
<Tabs
|
|
||||||
value={currentTab}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
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={<MyLocationIcon fontSize="small" />}
|
|
||||||
iconPosition="start"
|
|
||||||
label="模拟结果"
|
|
||||||
/> */}
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Tab 内容 */}
|
|
||||||
<TabPanel value={currentTab} index={0}>
|
|
||||||
<ContaminantAnalysisParameters />
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={1}>
|
|
||||||
<ContaminantSchemeQuery
|
|
||||||
schemes={schemes}
|
|
||||||
onSchemesChange={setSchemes}
|
|
||||||
onViewResults={() => setCurrentTab(2)}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TabPanelProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
index: number;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="tabpanel"
|
|
||||||
hidden={value !== index}
|
|
||||||
className="flex-1 overflow-hidden flex flex-col"
|
|
||||||
>
|
|
||||||
{value === index && (
|
|
||||||
<Box className="flex-1 overflow-auto p-4">{children}</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WaterQualityPanel;
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
"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";
|
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
|
||||||
|
|
||||||
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>(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 >= 360;
|
|
||||||
}, [schemeName, startTime, endTime, qSum]);
|
|
||||||
|
|
||||||
const handleRun = async () => {
|
|
||||||
if (!isValid || !startTime || !endTime) {
|
|
||||||
open?.({ type: "error", message: "请完善参数并确认时间范围合法" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRunning(true);
|
|
||||||
open?.({
|
|
||||||
key: "dma-leak-analysis-progress",
|
|
||||||
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: toM3s(qSum, FLOW_DISPLAY_UNIT),
|
|
||||||
q_sum_unit: "m3/s",
|
|
||||||
output_flow_unit: FLOW_DISPLAY_UNIT,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
onResult(response.data as LeakageResultDetail);
|
|
||||||
open?.({
|
|
||||||
key: "dma-leak-analysis-success",
|
|
||||||
type: "success",
|
|
||||||
message: "方案分析成功",
|
|
||||||
description: "DMA 漏损识别完成,请在方案查询中查看结果。",
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
open?.({
|
|
||||||
key: "dma-leak-analysis-error",
|
|
||||||
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);
|
|
||||||
// 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
|
|
||||||
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">
|
|
||||||
总漏损流量 ({FLOW_DISPLAY_UNIT})
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
size="small"
|
|
||||||
value={qSum}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = Number(e.target.value);
|
|
||||||
setQSum(Number.isNaN(value) ? 1440 : Math.max(360, value));
|
|
||||||
}}
|
|
||||||
inputProps={{ min: 360, 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="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;
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Drawer,
|
|
||||||
Tabs,
|
|
||||||
Tab,
|
|
||||||
Typography,
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
Analytics as AnalyticsIcon,
|
|
||||||
Search as SearchIcon,
|
|
||||||
ChevronLeft,
|
|
||||||
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 { getAreaColor } from "./utils";
|
|
||||||
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
|
||||||
import { config } from "@/config/config";
|
|
||||||
|
|
||||||
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 DMA_AREA_INDEX_PROPERTY = "dma_area_index";
|
|
||||||
|
|
||||||
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 [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]);
|
|
||||||
|
|
||||||
const drawerWidth = 450;
|
|
||||||
const panelTitle = "DMA 漏损识别";
|
|
||||||
const activeAreas = useMemo(() => loadedResult?.areas ?? [], [loadedResult]);
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAnalysisResult = useCallback((res: LeakageResultDetail) => {
|
|
||||||
setResult(res);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleViewResult = useCallback((res: LeakageResultDetail) => {
|
|
||||||
setResult(res);
|
|
||||||
setLoadedResult(res);
|
|
||||||
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 && (
|
|
||||||
<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} schemes={schemes} onSchemesChange={setSchemes} />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel value={tab} index={2}>
|
|
||||||
<RecognitionResults result={result} />
|
|
||||||
</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;
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
"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 { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
|
||||||
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 = String(
|
|
||||||
(result.scheme_detail as any)?.algorithm_params?.q_sum_unit ||
|
|
||||||
"m3/s",
|
|
||||||
);
|
|
||||||
const qSumM3h = toM3h(Number(val), unit);
|
|
||||||
return Number.isFinite(qSumM3h)
|
|
||||||
? `${qSumM3h.toFixed(3)} ${FLOW_DISPLAY_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;
|
|
||||||
const maxLeakageM3h = toM3h(Number(maxL), "m3/s");
|
|
||||||
return Number.isFinite(maxLeakageM3h)
|
|
||||||
? `${maxLeakageM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}`
|
|
||||||
: "-";
|
|
||||||
})()}
|
|
||||||
</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 }}
|
|
||||||
>
|
|
||||||
漏损量 ({FLOW_DISPLAY_UNIT})
|
|
||||||
</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" }}
|
|
||||||
>
|
|
||||||
{toM3h(Number(row.LeakageFlow_m3_per_s), "m3/s").toFixed(3)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RecognitionResults;
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
"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";
|
|
||||||
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, schemes: externalSchemes, onSchemesChange }) => {
|
|
||||||
const { open } = useNotification();
|
|
||||||
const [queryAll, setQueryAll] = useState(true);
|
|
||||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
|
|
||||||
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);
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
const nextSchemes = response.data as LeakageSchemeRecord[];
|
|
||||||
setSchemes(nextSchemes);
|
|
||||||
} 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);
|
|
||||||
const maxLeakageM3h = toM3h(value, "m3/s");
|
|
||||||
return Number.isFinite(maxLeakageM3h)
|
|
||||||
? `${maxLeakageM3h.toFixed(3)} ${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">
|
|
||||||
{(() => {
|
|
||||||
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",
|
|
||||||
);
|
|
||||||
const qSumM3h = toM3h(value, unit);
|
|
||||||
return Number.isFinite(qSumM3h)
|
|
||||||
? `${qSumM3h.toFixed(3)} ${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">
|
|
||||||
{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;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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];
|
|
||||||
};
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
"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 "@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 Feature, { FeatureLike } from "ol/Feature";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
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<string>(
|
|
||||||
"Flushing_" + new Date().getTime(),
|
|
||||||
);
|
|
||||||
const [valves, setValves] = useState<ValveItem[]>([]);
|
|
||||||
const [drainageNode, setDrainageNode] = useState<string | null>(null);
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
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, handleMapClickSelectFeatures]);
|
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// 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:00Z"); // ISO format with seconds set to 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 api.get(`${config.BACKEND_URL}/api/v1/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 (
|
|
||||||
<Box className="flex flex-col h-full gap-4 pb-4">
|
|
||||||
{/* 1. Valve Selection */}
|
|
||||||
<Box>
|
|
||||||
<Box className="flex items-center justify-between mb-2">
|
|
||||||
<Typography variant="subtitle2" className="font-medium">
|
|
||||||
参与阀门
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant={selectionMode === 'valve' ? "contained" : "outlined"}
|
|
||||||
color={selectionMode === 'valve' ? "error" : "primary"}
|
|
||||||
size="small"
|
|
||||||
onClick={() => toggleSelection('valve')}
|
|
||||||
>
|
|
||||||
{selectionMode === 'valve' ? "停止选择" : "选择阀门"}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{selectionMode === 'valve' && (
|
|
||||||
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
|
|
||||||
💡 点击地图上的阀门进行添加
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Stack spacing={1} className="max-h-50 h-48 overflow-auto">
|
|
||||||
{valves.map((valve) => (
|
|
||||||
<Box key={valve.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
|
||||||
<Typography className="text-sm flex-1 pl-1">{valve.id}</Typography>
|
|
||||||
<TextField
|
|
||||||
label="开度"
|
|
||||||
size="small"
|
|
||||||
type="number"
|
|
||||||
value={valve.k}
|
|
||||||
onChange={(e) => handleValveKChange(valve.id, e.target.value)}
|
|
||||||
className="w-20"
|
|
||||||
slotProps={{ htmlInput: { step: 0.1, min: 0, max: 1 } }}
|
|
||||||
/>
|
|
||||||
<IconButton size="small" onClick={() => handleRemoveValve(valve.id)}>
|
|
||||||
<CloseIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{valves.length === 0 && (
|
|
||||||
<Typography variant="caption" className="text-gray-400 text-center py-20">
|
|
||||||
暂无选中阀门
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* 2. Drainage Node Selection */}
|
|
||||||
<Box>
|
|
||||||
<Box className="flex items-center justify-between mb-2">
|
|
||||||
<Typography variant="subtitle2" className="font-medium">
|
|
||||||
排水节点
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant={selectionMode === 'drainage' ? "contained" : "outlined"}
|
|
||||||
color={selectionMode === 'drainage' ? "error" : "primary"}
|
|
||||||
size="small"
|
|
||||||
onClick={() => toggleSelection('drainage')}
|
|
||||||
>
|
|
||||||
{selectionMode === 'drainage' ? "停止选择" : "选择节点"}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{selectionMode === 'drainage' && (
|
|
||||||
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
|
|
||||||
💡 点击地图上的节点作为排水点
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Stack spacing={1} className="h-12 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>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setDrainageNode(null);
|
|
||||||
setDrainageFeature(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{!drainageNode && (
|
|
||||||
<Typography variant="caption" className="text-gray-400 text-center py-2">
|
|
||||||
暂无选中排水节点
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* 3. Parameters */}
|
|
||||||
<Box className="flex flex-col gap-3">
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
||||||
开始时间
|
|
||||||
</Typography>
|
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
|
|
||||||
<DateTimePicker
|
|
||||||
value={startTime}
|
|
||||||
onChange={(newValue) => setStartTime(newValue)}
|
|
||||||
format="YYYY-MM-DD HH:mm"
|
|
||||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
|
||||||
localeText={
|
|
||||||
pickerZhCN.components.MuiLocalizationProvider.defaultProps
|
|
||||||
.localeText
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</LocalizationProvider>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Scheme Name */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
||||||
方案名称
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
value={schemeName}
|
|
||||||
onChange={(e) => setSchemeName(e.target.value)}
|
|
||||||
placeholder="请输入方案名称"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="flex gap-2">
|
|
||||||
<Box className="flex-1">
|
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
||||||
冲洗流量 (CMH)
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
type="number"
|
|
||||||
value={flushFlow}
|
|
||||||
onChange={(e) => setFlushFlow(parseFloat(e.target.value) || 0)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box className="flex-1">
|
|
||||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
||||||
持续时长 (秒)
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
type="number"
|
|
||||||
value={duration}
|
|
||||||
onChange={(e) => setDuration(parseInt(e.target.value) || 0)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="mt-auto pt-2">
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
disabled={
|
|
||||||
analyzing ||
|
|
||||||
!schemeName.trim() ||
|
|
||||||
!drainageNode ||
|
|
||||||
!startTime ||
|
|
||||||
// !flushFlow ||
|
|
||||||
!duration
|
|
||||||
}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
{analyzing ? "分析中..." : "开始分析"}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalysisParameters;
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"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";
|
|
||||||
import { SchemeRecord } from "./types";
|
|
||||||
|
|
||||||
interface TabPanelProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
index: number;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="tabpanel"
|
|
||||||
hidden={value !== index}
|
|
||||||
className="flex-1 overflow-hidden flex flex-col"
|
|
||||||
>
|
|
||||||
{value === index && (
|
|
||||||
<Box className="flex-1 overflow-auto p-4">{children}</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FlushingAnalysisPanelProps {
|
|
||||||
open?: boolean;
|
|
||||||
onToggle?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({
|
|
||||||
open: controlledOpen,
|
|
||||||
onToggle,
|
|
||||||
}) => {
|
|
||||||
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;
|
|
||||||
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 && (
|
|
||||||
<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={handleToggle}
|
|
||||||
sx={{ zIndex: 1300 }}
|
|
||||||
>
|
|
||||||
<Box className="flex flex-col items-center py-3 px-3 gap-1">
|
|
||||||
<MdCleaningServices 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Panel */}
|
|
||||||
<Drawer
|
|
||||||
anchor="right"
|
|
||||||
open={isOpen}
|
|
||||||
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">
|
|
||||||
{/* Header */}
|
|
||||||
<Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white">
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<MdCleaningServices className="w-5 h-5" />
|
|
||||||
<Typography variant="h6" className="text-lg font-semibold">
|
|
||||||
{panelTitle}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Tooltip title="收起">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={handleToggle}
|
|
||||||
sx={{ color: "primary.contrastText" }}
|
|
||||||
>
|
|
||||||
<ChevronRight fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="border-b border-gray-200 bg-white">
|
|
||||||
<Tabs
|
|
||||||
value={currentTab}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
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="方案查询"
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<TabPanel value={currentTab} index={0}>
|
|
||||||
<AnalysisParameters />
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={1}>
|
|
||||||
<SchemeQuery schemes={schemes} onSchemesChange={setSchemes} />
|
|
||||||
</TabPanel>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FlushingAnalysisPanel;
|
|
||||||
@@ -1,613 +0,0 @@
|
|||||||
"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,
|
|
||||||
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 { api } from "@/lib/api";
|
|
||||||
import moment from "moment";
|
|
||||||
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 { 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 "@components/olmap/core/Controls/Timeline";
|
|
||||||
import { SchemeRecord, SchemaItem } from "./types";
|
|
||||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
|
||||||
schemes?: SchemeRecord[];
|
|
||||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
|
||||||
network?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCHEME_TYPE = "flushing_analysis";
|
|
||||||
|
|
||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|
||||||
schemes: externalSchemes,
|
|
||||||
onSchemesChange,
|
|
||||||
network = NETWORK_NAME,
|
|
||||||
}) => {
|
|
||||||
const [queryAll, setQueryAll] = useState<boolean>(true);
|
|
||||||
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date()));
|
|
||||||
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();
|
|
||||||
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 api.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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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?.({
|
|
||||||
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(
|
|
||||||
<Timeline
|
|
||||||
schemeDate={selectedDate}
|
|
||||||
timeRange={timeRange}
|
|
||||||
disableDateSelection={!!timeRange}
|
|
||||||
schemeName={schemeName}
|
|
||||||
schemeType={SCHEME_TYPE}
|
|
||||||
/>,
|
|
||||||
mapContainer,
|
|
||||||
)}
|
|
||||||
<Box className="flex flex-col h-full">
|
|
||||||
{/* Query Controls */}
|
|
||||||
<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
|
|
||||||
checked={queryAll}
|
|
||||||
onChange={(e) => setQueryAll(e.target.checked)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={<Typography variant="body2">查询全部</Typography>}
|
|
||||||
className="m-0"
|
|
||||||
/>
|
|
||||||
<LocalizationProvider
|
|
||||||
dateAdapter={AdapterDayjs}
|
|
||||||
adapterLocale="zh-cn"
|
|
||||||
>
|
|
||||||
<DatePicker
|
|
||||||
value={queryDate}
|
|
||||||
onChange={(value) =>
|
|
||||||
value && dayjs.isDayjs(value) && setQueryDate(value)
|
|
||||||
}
|
|
||||||
format="YYYY-MM-DD"
|
|
||||||
disabled={queryAll}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Results List */}
|
|
||||||
<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.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 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.schemeName}
|
|
||||||
>
|
|
||||||
{scheme.schemeName}
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label="冲洗模拟"
|
|
||||||
size="small"
|
|
||||||
className="h-5"
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
className="text-gray-500 block"
|
|
||||||
>
|
|
||||||
用户: {scheme.user} · 时间: {formatTime(scheme.create_time)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box className="flex gap-1 ml-2">
|
|
||||||
<Tooltip title="定位排水口">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
scheme.schemeDetail?.drainage_node_ID &&
|
|
||||||
handleLocateDrainageNode(scheme.schemeDetail.drainage_node_ID)
|
|
||||||
}
|
|
||||||
color="primary"
|
|
||||||
className="p-1"
|
|
||||||
>
|
|
||||||
<LocationIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
expandedId === scheme.id ? "收起详情" : "查看详情"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedId(
|
|
||||||
expandedId === scheme.id ? null : scheme.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
color="primary"
|
|
||||||
className="p-1"
|
|
||||||
>
|
|
||||||
<InfoIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Collapse in={expandedId === scheme.id}>
|
|
||||||
<Box className="mt-2 pt-3 border-t border-gray-200">
|
|
||||||
<Box className="grid grid-cols-2 gap-x-4 gap-y-3 mb-3">
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Box className="space-y-1.5 pl-2">
|
|
||||||
{/* 排水节点 */}
|
|
||||||
<Box className="flex items-start gap-2">
|
|
||||||
<Typography variant="caption" className="text-gray-600 min-w-[70px] mt-0.5">
|
|
||||||
排水节点:
|
|
||||||
</Typography>
|
|
||||||
<Box className="flex-1">
|
|
||||||
{scheme.schemeDetail?.drainage_node_ID ? (
|
|
||||||
<Link
|
|
||||||
component="button"
|
|
||||||
variant="caption"
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-800 underline cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleLocateDrainageNode(scheme.schemeDetail!.drainage_node_ID);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scheme.schemeDetail.drainage_node_ID}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
|
||||||
N/A
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 冲洗流量 */}
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
|
|
||||||
冲洗流量:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
|
||||||
{scheme.schemeDetail?.flushing_flow ?? "-"} {FLOW_DISPLAY_UNIT}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 持续时长 */}
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
|
|
||||||
持续时长:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
|
||||||
{scheme.schemeDetail?.duration ?? "-"} 秒
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Box className="space-y-1.5 pl-2">
|
|
||||||
{/* 用户 */}
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
|
|
||||||
用户:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
|
||||||
{scheme.user}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 创建时间 */}
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
|
|
||||||
创建时间:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
|
||||||
{formatTime(scheme.create_time)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 开始时间 */}
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
|
|
||||||
模拟开始:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" className="font-medium text-gray-900">
|
|
||||||
{formatTime(scheme.startTime)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 阀门列表 */}
|
|
||||||
<Box className="col-span-2 pl-2">
|
|
||||||
<Typography variant="caption" className="text-gray-600 block mb-1">
|
|
||||||
参与阀门及开度:
|
|
||||||
</Typography>
|
|
||||||
<Box className="flex flex-wrap gap-2">
|
|
||||||
{scheme.schemeDetail?.valve_opening && Object.entries(scheme.schemeDetail.valve_opening).length > 0 ? (
|
|
||||||
Object.entries(scheme.schemeDetail.valve_opening).map(([id, k]) => (
|
|
||||||
<Tooltip key={id} title="点击定位阀门">
|
|
||||||
<Chip
|
|
||||||
label={`${id}: ${k}`}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => handleLocateValves([id])}
|
|
||||||
className="text-xs h-6 bg-gray-50 cursor-pointer hover:bg-orange-50 hover:border-orange-200"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Typography variant="caption" className="text-gray-400">无</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="pt-2 border-t border-gray-100 flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
className="border-blue-600 text-blue-600 hover:bg-blue-50"
|
|
||||||
onClick={() =>
|
|
||||||
scheme.schemeDetail?.drainage_node_ID &&
|
|
||||||
handleLocateDrainageNode(scheme.schemeDetail.drainage_node_ID)
|
|
||||||
}
|
|
||||||
startIcon={<LocationIcon className="w-4 h-4" />}
|
|
||||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
定位排水口
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
onClick={() => handleViewResults(scheme)}
|
|
||||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
查看模拟结果
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SchemeQuery;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export interface SchemeDetail {
|
|
||||||
valve_opening: Record<string, number>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
|
||||||
@@ -27,9 +27,9 @@ import dayjs from "dayjs";
|
|||||||
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
|
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
|
||||||
import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb";
|
import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb";
|
||||||
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
||||||
|
import { useData } from "../../../app/OlMap/MapComponent";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
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 { useHealthRisk } from "./HealthRiskContext";
|
||||||
import {
|
import {
|
||||||
PredictionResult,
|
PredictionResult,
|
||||||
@@ -59,8 +59,20 @@ interface TimelineProps {
|
|||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Timeline: React.FC<TimelineProps> = ({
|
const Timeline: React.FC<TimelineProps> = (props) => {
|
||||||
|
const data = useData();
|
||||||
|
if (!data) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TimelineContent {...props} data={data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimelineContent: React.FC<
|
||||||
|
TimelineProps & { data: NonNullable<ReturnType<typeof useData>> }
|
||||||
|
> = ({
|
||||||
disableDateSelection = false,
|
disableDateSelection = false,
|
||||||
|
data,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const {
|
const {
|
||||||
@@ -74,6 +86,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
||||||
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
const [isPredicting, setIsPredicting] = useState<boolean>(false);
|
||||||
|
const [pipeLayer, setPipeLayer] = useState<WebGLVectorTileLayer | null>(null);
|
||||||
|
|
||||||
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
|
||||||
const healthDataRef = useRef<Map<string, number>>(new Map());
|
const healthDataRef = useRef<Map<string, number>>(new Map());
|
||||||
@@ -117,7 +130,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
setCurrentYear(value);
|
setCurrentYear(value);
|
||||||
}, 500); // 500ms 防抖延迟
|
}, 500); // 500ms 防抖延迟
|
||||||
},
|
},
|
||||||
[minTime, maxTime, setCurrentYear],
|
[minTime, maxTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 播放控制
|
// 播放控制
|
||||||
@@ -133,7 +146,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
});
|
});
|
||||||
}, playInterval);
|
}, playInterval);
|
||||||
}
|
}
|
||||||
}, [isPlaying, playInterval, maxTime, minTime, setCurrentYear]);
|
}, [isPlaying, playInterval]);
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -172,7 +185,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (next < minTime) next += maxTime - minTime + 1;
|
if (next < minTime) next += maxTime - minTime + 1;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [minTime, maxTime, setCurrentYear]);
|
}, [minTime, maxTime]);
|
||||||
|
|
||||||
const handleStepForward = useCallback(() => {
|
const handleStepForward = useCallback(() => {
|
||||||
setCurrentYear((prev: number) => {
|
setCurrentYear((prev: number) => {
|
||||||
@@ -180,7 +193,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (next > maxTime) next = minTime;
|
if (next > maxTime) next = minTime;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [minTime, maxTime, setCurrentYear]);
|
}, [minTime, maxTime]);
|
||||||
|
|
||||||
// 日期时间选择处理
|
// 日期时间选择处理
|
||||||
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
||||||
@@ -207,7 +220,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, newInterval);
|
}, newInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying, maxTime, minTime, setCurrentYear],
|
[isPlaying],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 组件加载时设置初始时间为当前时间的最近15分钟
|
// 组件加载时设置初始时间为当前时间的最近15分钟
|
||||||
@@ -222,21 +235,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [pipeLayer]);
|
||||||
|
|
||||||
// 获取地图实例
|
// 获取地图实例
|
||||||
const map = useMap();
|
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 中获取生存概率
|
// 根据索引从 survival_function 中获取生存概率
|
||||||
const getSurvivalProbabilityAtYear = useCallback(
|
const getSurvivalProbabilityAtYear = useCallback(
|
||||||
@@ -367,12 +369,27 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
updatePipeHealthData,
|
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(() => {
|
useEffect(() => {
|
||||||
if (predictionResults.length > 0 && pipeLayer) {
|
if (predictionResults.length > 0 && pipeLayer) {
|
||||||
applyPipeHealthStyle();
|
applyPipeHealthStyle();
|
||||||
}
|
}
|
||||||
}, [applyPipeHealthStyle, pipeLayer, predictionResults.length]);
|
}, [applyPipeHealthStyle]);
|
||||||
|
|
||||||
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -413,7 +430,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
undoableTimeout: 3,
|
undoableTimeout: 3,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await fetch(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -475,7 +492,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
|
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
|
||||||
>
|
>
|
||||||
<Tooltip title="后退一天">
|
<Tooltip title="后退一天">
|
||||||
<span>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDayStepBackward}
|
onClick={handleDayStepBackward}
|
||||||
@@ -484,7 +500,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 日期时间选择器 */}
|
{/* 日期时间选择器 */}
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -507,7 +522,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
ampm={false}
|
ampm={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip title="前进一天">
|
<Tooltip title="前进一天">
|
||||||
<span>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDayStepForward}
|
onClick={handleDayStepForward}
|
||||||
@@ -520,7 +534,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
>
|
>
|
||||||
<FiSkipForward />
|
<FiSkipForward />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 播放控制按钮 */}
|
{/* 播放控制按钮 */}
|
||||||
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { useGetIdentity } from "@refinedev/core";
|
import { useGetIdentity } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: string;
|
id: number;
|
||||||
name?: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OptimizationParameters: React.FC = () => {
|
const OptimizationParameters: React.FC = () => {
|
||||||
@@ -83,7 +83,7 @@ const OptimizationParameters: React.FC = () => {
|
|||||||
|
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
|
|
||||||
if (!user || !user.id) {
|
if (!user || !user.name) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "用户信息无效",
|
message: "用户信息无效",
|
||||||
@@ -93,7 +93,7 @@ const OptimizationParameters: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 发送优化请求
|
// 发送优化请求
|
||||||
const response = await api.post(
|
const response = await axios.post(
|
||||||
`${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`,
|
`${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`,
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
@@ -104,7 +104,6 @@ const OptimizationParameters: React.FC = () => {
|
|||||||
method: method,
|
method: method,
|
||||||
sensor_count: sensorCount,
|
sensor_count: sensorCount,
|
||||||
min_diameter: minDiameter,
|
min_diameter: minDiameter,
|
||||||
user_id: user.id,
|
|
||||||
user_name: user.name,
|
user_name: user.name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
import "dayjs/locale/zh-cn"; // 引入中文包
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { config, NETWORK_NAME } from "@config/config";
|
import { config, NETWORK_NAME } from "@config/config";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@app/OlMap/MapComponent";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { GeoJSON } from "ol/format";
|
import { GeoJSON } from "ol/format";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
@@ -140,7 +140,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [highlightFeatures, highlightLayer]);
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
// 查询方案
|
// 查询方案
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
@@ -148,7 +148,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const response = await axios.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,7 +163,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
setSchemes(
|
||||||
|
filteredResults.map((item: SchemaItem) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
sensorNumber: item.sensor_number,
|
sensorNumber: item.sensor_number,
|
||||||
@@ -171,8 +172,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
user: item.username,
|
user: item.username,
|
||||||
create_time: item.create_time,
|
create_time: item.create_time,
|
||||||
sensorLocation: item.sensor_location,
|
sensorLocation: item.sensor_location,
|
||||||
}));
|
})),
|
||||||
setSchemes(nextSchemes);
|
);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
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 "@app/OlMap/MapComponent";
|
||||||
|
|
||||||
|
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 [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 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`,
|
||||||
|
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]);
|
||||||
|
// 获取中文标签
|
||||||
|
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;
|
||||||
+14
-54
@@ -37,15 +37,14 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
|||||||
import config from "@/config/config";
|
import config from "@/config/config";
|
||||||
import { useGetIdentity } from "@refinedev/core";
|
import { useGetIdentity } from "@refinedev/core";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: string;
|
id: number;
|
||||||
name?: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TimeSeriesPoint {
|
export interface TimeSeriesPoint {
|
||||||
@@ -68,10 +67,6 @@ export interface SCADADataPanelProps {
|
|||||||
showCleaning?: boolean;
|
showCleaning?: boolean;
|
||||||
/** 清洗数据的回调 */
|
/** 清洗数据的回调 */
|
||||||
onCleanData?: () => void;
|
onCleanData?: () => void;
|
||||||
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
|
|
||||||
start_time?: string;
|
|
||||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
|
||||||
end_time?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelTab = "chart" | "table";
|
type PanelTab = "chart" | "table";
|
||||||
@@ -101,10 +96,10 @@ const fetchFromBackend = async (
|
|||||||
try {
|
try {
|
||||||
// 优先查询清洗数据和模拟数据
|
// 优先查询清洗数据和模拟数据
|
||||||
const [cleaningRes, simulationRes] = await Promise.all([
|
const [cleaningRes, simulationRes] = await Promise.all([
|
||||||
apiFetch(cleaningDataUrl)
|
fetch(cleaningDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(simulationDataUrl)
|
fetch(simulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -123,7 +118,7 @@ const fetchFromBackend = async (
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
||||||
const rawRes = await apiFetch(rawDataUrl)
|
const rawRes = await fetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
const rawData = transformBackendData(rawRes, deviceIds);
|
const rawData = transformBackendData(rawRes, deviceIds);
|
||||||
@@ -318,8 +313,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
fractionDigits = 2,
|
fractionDigits = 2,
|
||||||
showCleaning = false,
|
showCleaning = false,
|
||||||
onCleanData,
|
onCleanData,
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
const { data: user } = useGetIdentity<IUser>();
|
||||||
@@ -345,13 +338,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}`;
|
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||||
try {
|
try {
|
||||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||||
apiFetch(cleaningDataUrl)
|
fetch(cleaningDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(rawDataUrl)
|
fetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(simulationDataUrl)
|
fetch(simulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -402,24 +395,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [showCleaning]);
|
}, [showCleaning]);
|
||||||
|
|
||||||
const [from, setFrom] = useState<Dayjs>(() => {
|
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
||||||
if (start_time) {
|
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
||||||
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 [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
@@ -434,27 +411,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
}, [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 normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||||
|
|
||||||
const hasDevices = deviceIds.length > 0;
|
const hasDevices = deviceIds.length > 0;
|
||||||
const hasData = timeSeries.length > 0;
|
const hasData = timeSeries.length > 0;
|
||||||
const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]);
|
|
||||||
|
|
||||||
const dataset = useMemo(
|
const dataset = useMemo(
|
||||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||||
@@ -498,7 +458,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.id) {
|
if (!user || !user.name) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "用户信息无效,请重新登录",
|
message: "用户信息无效,请重新登录",
|
||||||
@@ -514,7 +474,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const endTime = dayjs(rangeTo).toISOString();
|
const endTime = dayjs(rangeTo).toISOString();
|
||||||
|
|
||||||
// 调用后端清洗接口
|
// 调用后端清洗接口
|
||||||
const response = await api.post(
|
const response = await axios.post(
|
||||||
`${
|
`${
|
||||||
config.BACKEND_URL
|
config.BACKEND_URL
|
||||||
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join(
|
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join(
|
||||||
@@ -567,7 +527,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
}
|
}
|
||||||
}, [deviceIdsKey, handleFetch, hasDevices]);
|
}, [deviceIds.join(",")]);
|
||||||
|
|
||||||
// 当设备数量变化时,调整数据源选择
|
// 当设备数量变化时,调整数据源选择
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
+10
-16
@@ -48,12 +48,11 @@ import {
|
|||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { FixedSizeList } from "react-window";
|
import { FixedSizeList } from "react-window";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import axios from "axios";
|
||||||
import { useGetIdentity } from "@refinedev/core";
|
import { useGetIdentity } from "@refinedev/core";
|
||||||
import config from "@/config/config";
|
import config, { NETWORK_NAME } from "@/config/config";
|
||||||
|
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@app/OlMap/MapComponent";
|
||||||
import { useProject } from "@/contexts/ProjectContext";
|
|
||||||
import { GeoJSON } from "ol/format";
|
import { GeoJSON } from "ol/format";
|
||||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
@@ -104,8 +103,8 @@ interface SCADADeviceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: string;
|
id: number;
|
||||||
name?: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
||||||
@@ -181,17 +180,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
}
|
}
|
||||||
}, [pendingSelection, onSelectionChange]);
|
}, [pendingSelection, onSelectionChange]);
|
||||||
|
|
||||||
// Get workspace from context
|
|
||||||
const project = useProject();
|
|
||||||
const workspace = project?.workspace;
|
|
||||||
|
|
||||||
// 初始化 SCADA 设备列表
|
// 初始化 SCADA 设备列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchScadaDevices = async () => {
|
const fetchScadaDevices = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const activeWorkspace = workspace || config.MAP_WORKSPACE;
|
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 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);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error("Failed to fetch SCADA devices");
|
if (!response.ok) throw new Error("Failed to fetch SCADA devices");
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
@@ -217,7 +211,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchScadaDevices();
|
fetchScadaDevices();
|
||||||
}, [workspace]);
|
}, []);
|
||||||
|
|
||||||
const effectiveDevices = devices.length > 0 ? devices : internalDevices;
|
const effectiveDevices = devices.length > 0 ? devices : internalDevices;
|
||||||
|
|
||||||
@@ -601,7 +595,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.id) {
|
if (!user || !user.name) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "用户信息无效,请重新登录",
|
message: "用户信息无效,请重新登录",
|
||||||
@@ -622,7 +616,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
const endTime = dayjs(cleanEndTime).toISOString();
|
const endTime = dayjs(cleanEndTime).toISOString();
|
||||||
|
|
||||||
// 调用后端清洗接口
|
// 调用后端清洗接口
|
||||||
const response = await api.post(
|
const response = await axios.post(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -741,7 +735,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [selectedDeviceIds, highlightFeatures, highlightLayer]);
|
}, [selectedDeviceIds, highlightFeatures]);
|
||||||
// 清理定时器
|
// 清理定时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo } from "react";
|
|
||||||
import { useData, useMap } from "../MapComponent";
|
|
||||||
import { Checkbox, FormControlLabel } from "@mui/material";
|
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
|
||||||
import VectorLayer from "ol/layer/Vector";
|
|
||||||
import VectorTileLayer from "ol/layer/VectorTile";
|
|
||||||
import { DeckLayer } from "@utils/layers";
|
|
||||||
|
|
||||||
// 定义统一的图层项接口
|
|
||||||
interface LayerItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
visible: boolean;
|
|
||||||
type: "ol" | "deck";
|
|
||||||
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();
|
|
||||||
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 layerItems = useMemo(() => {
|
|
||||||
void refreshKey;
|
|
||||||
|
|
||||||
if (!map || !data) return [];
|
|
||||||
|
|
||||||
const items: LayerItem[] = [];
|
|
||||||
|
|
||||||
map.getLayers().getArray().forEach((layer) => {
|
|
||||||
if (
|
|
||||||
layer instanceof WebGLVectorTileLayer ||
|
|
||||||
layer instanceof VectorTileLayer ||
|
|
||||||
layer instanceof VectorLayer
|
|
||||||
) {
|
|
||||||
const value = layer.get("value");
|
|
||||||
const name = layer.get("name");
|
|
||||||
if (value) {
|
|
||||||
items.push({
|
|
||||||
id: value,
|
|
||||||
name: name || value,
|
|
||||||
visible: layer.getVisible(),
|
|
||||||
type: "ol",
|
|
||||||
layerRef: layer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (deckLayer && deckLayer instanceof DeckLayer) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
.filter((item) => LAYER_ORDER.includes(item.id))
|
|
||||||
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
|
|
||||||
}, [
|
|
||||||
map,
|
|
||||||
data,
|
|
||||||
deckLayer,
|
|
||||||
isContourLayerAvailable,
|
|
||||||
isWaterflowLayerAvailable,
|
|
||||||
refreshKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const layerCollection = map.getLayers();
|
|
||||||
const handleLayerChange = () => {
|
|
||||||
setRefreshKey((prev) => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
layerCollection.on("change:length", handleLayerChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.getLayers().un("change:length", handleLayerChange);
|
|
||||||
};
|
|
||||||
}, [map]);
|
|
||||||
|
|
||||||
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
|
|
||||||
if (item.type === "ol") {
|
|
||||||
item.layerRef.setVisible(checked);
|
|
||||||
} else if (item.type === "deck" && deckLayer) {
|
|
||||||
if (item.id === "junctionContourLayer") {
|
|
||||||
setShowContourLayer && setShowContourLayer(checked);
|
|
||||||
}
|
|
||||||
if (item.id === "waterflowLayer") {
|
|
||||||
setShowWaterflowLayer && setShowWaterflowLayer(checked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRefreshKey((prev) => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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
|
|
||||||
key={item.id}
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={item.visible}
|
|
||||||
onChange={(e) => handleVisibilityChange(item, e.target.checked)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={item.name}
|
|
||||||
sx={{
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
"& .MuiFormControlLabel-label": { fontSize: "0.7rem" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LayerControl;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
const updateZoomLevel = () => {
|
|
||||||
const zoom = map.getView().getZoom();
|
|
||||||
setZoomLevel(zoom ?? 0); // 如果 zoom 是 undefined,则使用默认值 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCoordinates = (event: any) => {
|
|
||||||
const coords = event.coordinate;
|
|
||||||
const transformedCoords = coords.map((c: number) =>
|
|
||||||
parseFloat(c.toFixed(4))
|
|
||||||
);
|
|
||||||
setCoordinates(transformedCoords);
|
|
||||||
};
|
|
||||||
|
|
||||||
map.on("moveend", updateZoomLevel);
|
|
||||||
map.on("pointermove", updateCoordinates);
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<>
|
|
||||||
<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-20 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Scale;
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import { Title } from "@components/title";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
TextField,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Fade,
|
|
||||||
IconButton,
|
|
||||||
} from "@mui/material";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
|
||||||
|
|
||||||
interface ProjectSelectorProps {
|
|
||||||
open: boolean;
|
|
||||||
onSelect: (
|
|
||||||
projectId: string,
|
|
||||||
workspace: string,
|
|
||||||
networkName: string,
|
|
||||||
extent: number[],
|
|
||||||
) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [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`,
|
|
||||||
{ projectHeaderMode: "omit" },
|
|
||||||
);
|
|
||||||
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 = () => {
|
|
||||||
if (!projectId.trim()) {
|
|
||||||
setProjectIdError("项目 ID 不能为空");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setProjectIdError(null);
|
|
||||||
onSelect(projectId.trim(), workspace, networkName, extent);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
disableEscapeKeyDown={!onClose}
|
|
||||||
onClose={onClose ? onClose : undefined}
|
|
||||||
slotProps={{
|
|
||||||
paper: {
|
|
||||||
sx: {
|
|
||||||
borderRadius: 2,
|
|
||||||
padding: 2,
|
|
||||||
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 />
|
|
||||||
</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={projectId}
|
|
||||||
label="项目"
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (val === "custom") {
|
|
||||||
setCustomMode(true);
|
|
||||||
setProjectIdError(null);
|
|
||||||
} else {
|
|
||||||
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.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>
|
|
||||||
))}
|
|
||||||
<MenuItem value="custom">
|
|
||||||
<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);
|
|
||||||
setProjectIdError(null);
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
helperText={
|
|
||||||
projectIdError || "例如: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
||||||
}
|
|
||||||
error={Boolean(projectIdError)}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user