Compare commits
106 Commits
master
...
46a4d7157d
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a4d7157d | |||
| 3ba252462d | |||
| 5ca9a55a7b | |||
| 9206c480b2 | |||
| 23bd2f47c3 | |||
| c4269f40e3 | |||
| 3afe885cc0 | |||
| b99fe66704 | |||
| c2785f0746 | |||
| 1ed09c9594 | |||
| baa5d41bec | |||
| 05868c6af6 | |||
| e81305d046 | |||
| b963562a5f | |||
| bfd41b58e3 | |||
| 333d0d3353 | |||
| f207e2b192 | |||
| 4f195b0e06 | |||
| 0f110ce0c6 | |||
| a23626614f | |||
| 1debaed7ea | |||
| 74b4a4157c | |||
| efd04fd651 | |||
| 5aa28c8409 | |||
| 8b6dda08e6 | |||
| 427cbe70b3 | |||
| 6410df0cb7 | |||
| ff5cbfde9c | |||
| 5cbf1e82f8 | |||
| 259202ca8f | |||
| bfa4020239 | |||
| 5dab6464c3 | |||
| b752be498a | |||
| 781711943a | |||
| 7d05ad4920 | |||
| f0fad61bb2 | |||
| d763876f86 | |||
| 56b4777dbd | |||
| c484aad1d3 | |||
| d610a09c14 | |||
| a1c8041b11 | |||
| 295c959b52 | |||
| adc12c13f9 | |||
| 6559d0c062 | |||
| a101e79750 | |||
| 8713e5a468 | |||
| 03a77f7368 | |||
| 825acbf29c | |||
| 045391d036 | |||
| accf6ad254 | |||
| 55362bef8f | |||
| d232104aa4 | |||
| e1e4664dec | |||
| e0ab4bf60d | |||
| abfc8770a4 | |||
| 71be47b956 | |||
| 081e4c4c13 | |||
| a7106a7289 | |||
| 76aa28c701 | |||
| a7f4867afe | |||
| e2ea1853f1 | |||
| f0f9d3f4f9 | |||
| 73201ae44e | |||
| 62914f80c3 | |||
| 64dcf9cbdb | |||
| 520e1cb3f1 | |||
| 7f25bd34d5 | |||
| 47e47fc605 | |||
| b4ab3e287b | |||
| ddb02cc688 | |||
| 6b68b7d081 | |||
| 2f24ab5d66 | |||
| 133880f7fc | |||
| 5ed6740a24 | |||
| 9beba1cf6f | |||
| bf6edf2662 | |||
| 5430a9d885 | |||
| 377fc32f4c | |||
| b73481d604 | |||
| cd34e511ac | |||
| 6c5862f7e4 | |||
| 2d27e803a3 | |||
| f9dc4b74d0 | |||
| 66f2390078 | |||
| 9d06226cb4 | |||
| a2e6c1f416 | |||
| 2911b87fac | |||
| 8b6198a2ac | |||
| 03e5f1456c | |||
| 25bde02b43 | |||
| 1e8af75b88 | |||
| 8ea70d04ad | |||
| 1d15eeb172 | |||
| ae1f9b284f | |||
| 409057cef2 | |||
| 2c51785157 | |||
| 6be4a0de14 | |||
| 9d12b1960c | |||
| cbfce9164e | |||
| 62a97459d0 | |||
| 4fbe845015 | |||
| f89e43eee2 | |||
| 4bd7b48bcf | |||
| 5b52afcc53 | |||
| 9bb0f8dcd7 | |||
| bc73db66de |
+9
-6
@@ -1,7 +1,10 @@
|
|||||||
**/node_modules/
|
node_modules
|
||||||
**/dist
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
.git
|
.git
|
||||||
npm-debug.log
|
.env*.local
|
||||||
.coverage
|
README.md
|
||||||
.coverage.*
|
docker-compose.yml
|
||||||
.env
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
name: Build Push and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-image:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: sh
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
env:
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
GIT_USERNAME: ${{ github.actor }}
|
||||||
|
GIT_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
case "$SERVER_URL" in
|
||||||
|
http://*)
|
||||||
|
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
|
||||||
|
;;
|
||||||
|
https://*)
|
||||||
|
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
AUTH_SERVER_URL="$SERVER_URL"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
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#/}"
|
||||||
|
REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}"
|
||||||
|
case "$IMAGE_TAG" in
|
||||||
|
*-test) IS_TEST_TAG=true ;;
|
||||||
|
*) IS_TEST_TAG=false ;;
|
||||||
|
esac
|
||||||
|
{
|
||||||
|
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
||||||
|
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
||||||
|
echo "IMAGE_NAME=${IMAGE_NAME}"
|
||||||
|
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||||
|
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
echo "IS_TEST_TAG=${IS_TEST_TAG}"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: |
|
||||||
|
case "${{ github.ref_name }}" in
|
||||||
|
*-test)
|
||||||
|
echo "Test tag detected; skipping registry login."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \
|
||||||
|
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- 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 }}" \
|
||||||
|
.
|
||||||
|
case "${{ github.ref_name }}" in
|
||||||
|
*-test)
|
||||||
|
echo "Test tag detected; build completed without pushing images."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
docker push "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
docker push "${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
|
- name: Notify Deploy Server
|
||||||
|
run: |
|
||||||
|
case "${{ github.ref_name }}" in
|
||||||
|
*-test)
|
||||||
|
echo "Test tag detected; skipping deploy webhook."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
|
||||||
|
-d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
|
||||||
|
|
||||||
|
deploy-fallback-log:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: docker-image
|
||||||
|
if: failure()
|
||||||
|
steps:
|
||||||
|
- name: Deployment not triggered
|
||||||
|
run: echo "Image build/push failed, deployment webhook was not called."
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 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.
|
||||||
@@ -34,3 +34,4 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
memery.md
|
||||||
|
|||||||
+16
-4
@@ -1,4 +1,4 @@
|
|||||||
FROM refinedev/node:18 AS base
|
FROM refinedev/node:22 AS base
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
@@ -15,6 +15,18 @@ 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 . .
|
||||||
@@ -23,7 +35,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
|
||||||
|
|
||||||
@@ -37,7 +49,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"]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
|
||||||
|
const config = [...nextCoreWebVitals];
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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.
|
||||||
|
- **Root cause found on the real act_runner:** although the runner was registered with `ubuntu:docker://gitea/runner-images:ubuntu-22.04`, the workflow used `runs-on: ubuntu`, and the job log showed `Start image=ubuntu:latest`. That default image does not include the expected toolset, which explains the remote `git: not found` failure.
|
||||||
|
- **Applied fix for label selection:** changed both jobs to `runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04"` so Gitea resolves the exact runner image instead of falling back to `ubuntu:latest`.
|
||||||
|
- **Follow-up from server validation:** Gitea then reported `No matching online runner with label: ubuntu:docker://gitea/runner-images:ubuntu-22.04`. The runner advertises the short label `ubuntu-22.04`, so the workflow was updated again to use `runs-on: ubuntu-22.04`, which should map to `docker://gitea/runner-images:ubuntu-22.04` on the runner side.
|
||||||
|
- **Next remote failure on act_runner:** Docker rejected the tag `gitea.waternetwork.cn/OrgTJWater/TJWaterFrontend_Refine:v2026.04.24-test3` with `repository name must be lowercase`. The workflow had normalized the registry host but not the repository path from `github.repository`.
|
||||||
|
- **Applied fix for image naming:** lowercased `REPOSITORY_PATH` during image metadata normalization so image tags remain valid even when the Gitea owner or repository name contains uppercase letters.
|
||||||
|
- **Latest remote failure on act_runner:** a `*-test` run still reached `Notify Deploy Server` and failed with `curl: (3) URL using bad/illegal format or missing URL`. That showed the shell-level `IS_TEST_TAG` guard was not reliable enough for cross-step skip control on this runner.
|
||||||
|
- **Applied fix for test-tag skipping:** moved registry login and deploy webhook skipping to workflow-level `if:` conditions based on `endsWith(github.ref_name, '-test')`, and made the image-push branch check the tag name directly instead of relying on `IS_TEST_TAG` from a previous step.
|
||||||
|
- **Follow-up from server validation:** the runner still executed `Notify Deploy Server` for `v2026.04.24-test5`, so Gitea step-level `if:` with `endsWith(...)` was not sufficient in this environment.
|
||||||
|
- **Applied hardening:** replaced those step-level conditions with direct shell `case "${{ github.ref_name }}" in *-test)` guards inside the login, push, and deploy steps. This avoids relying on Gitea expression behavior for test-tag skipping.
|
||||||
|
- **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment.
|
||||||
|
- **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow.
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
/** @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
+3053
-886
File diff suppressed because it is too large
Load Diff
+23
-11
@@ -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": "next lint",
|
"lint": "eslint .",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
@@ -24,12 +24,10 @@
|
|||||||
"@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/cli": "^2.16.50",
|
"@refinedev/core": "^5.0.12",
|
||||||
"@refinedev/core": "^5.0.8",
|
|
||||||
"@refinedev/devtools": "^2.0.3",
|
|
||||||
"@refinedev/kbar": "^2.0.1",
|
"@refinedev/kbar": "^2.0.1",
|
||||||
"@refinedev/mui": "^8.0.0",
|
"@refinedev/mui": "^8.0.2",
|
||||||
"@refinedev/nextjs-router": "^7.0.4",
|
"@refinedev/nextjs-router": "^7.0.5",
|
||||||
"@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",
|
||||||
@@ -39,19 +37,32 @@
|
|||||||
"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": "^15.5.11",
|
"next": "^16.1.6",
|
||||||
"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.1.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.2.4",
|
||||||
"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",
|
||||||
"tailwindcss": "^4.1.13"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"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",
|
||||||
@@ -62,9 +73,10 @@
|
|||||||
"@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": "^15.0.3",
|
"eslint-config-next": "^16.1.6",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tjwater-app",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
|
||||||
"build": "refine build",
|
|
||||||
"start": "refine start",
|
|
||||||
"lint": "next lint",
|
|
||||||
"refine": "refine"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@emotion/react": "^11.8.2",
|
|
||||||
"@emotion/styled": "^11.8.1",
|
|
||||||
"@mui/icons-material": "^6.1.6",
|
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
|
||||||
"@mui/material": "^6.1.7",
|
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
|
||||||
"@refinedev/cli": "^2.16.48",
|
|
||||||
"@refinedev/core": "^5.0.0",
|
|
||||||
"@refinedev/devtools": "^2.0.1",
|
|
||||||
"@refinedev/kbar": "^2.0.0",
|
|
||||||
"@refinedev/mui": "^7.0.0",
|
|
||||||
"@refinedev/nextjs-router": "^7.0.0",
|
|
||||||
"@refinedev/react-hook-form": "^5.0.0",
|
|
||||||
"@refinedev/simple-rest": "^6.0.0",
|
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
|
||||||
"@turf/turf": "^7.2.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"deck.gl": "^9.1.14",
|
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"next": "^15.2.4",
|
|
||||||
"next-auth": "^4.24.5",
|
|
||||||
"ol": "^10.6.1",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"tailwindcss": "^4.1.13"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@svgr/webpack": "^8.1.0",
|
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19.1.0",
|
|
||||||
"@types/react-dom": "^19.1.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-next": "^15.0.3",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
},
|
|
||||||
"refine": {
|
|
||||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
import { 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 "@app/OlMap/Controls/StyleLegend";
|
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
import {
|
import {
|
||||||
RAINBOW_COLORS,
|
RAINBOW_COLORS,
|
||||||
RISK_BREAKS,
|
RISK_BREAKS,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
|
<MapComponent>
|
||||||
|
<MapToolbar queryType="scheme" schemeType="burst_detection" hiddenButtons={["style"]} />
|
||||||
|
<BurstDetectionPanel />
|
||||||
|
</MapComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <MapSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <MapSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <MapSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <MapSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"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,7 +1,6 @@
|
|||||||
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";
|
||||||
@@ -33,7 +32,6 @@ export default async function MainLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RefineContext defaultMode={defaultMode}>
|
|
||||||
<ThemedLayout
|
<ThemedLayout
|
||||||
Header={Header}
|
Header={Header}
|
||||||
Title={Title}
|
Title={Title}
|
||||||
@@ -48,7 +46,6 @@ export default async function MainLayout({
|
|||||||
{children}
|
{children}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ThemedLayout>
|
</ThemedLayout>
|
||||||
</RefineContext>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/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 (
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
"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 "@app/OlMap/MapComponent";
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
import Timeline from "@app/OlMap/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
"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 "@app/OlMap/MapComponent";
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
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 map = useMap();
|
|
||||||
const data = useData();
|
|
||||||
if (!data) return;
|
|
||||||
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;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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;
|
|
||||||
+70
-12
@@ -8,19 +8,24 @@ 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 from "react";
|
import React, { useEffect } 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 } from "react-icons/tb";
|
import { TbDatabaseEdit, TbLocationPin, TbActivity } 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 { TbLocationPin } from "react-icons/tb";
|
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
||||||
import { AiOutlinePartition } from "react-icons/ai";
|
import {
|
||||||
|
MyLocation as MyLocationIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
type RefineContextProps = {
|
type RefineContextProps = {
|
||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
@@ -31,7 +36,9 @@ export const RefineContext = (
|
|||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
|
<ProjectProvider>
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
|
</ProjectProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -43,6 +50,11 @@ 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>;
|
||||||
@@ -99,6 +111,7 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -154,19 +167,64 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "风险分析定位",
|
name: "Hydraulic Simulation",
|
||||||
list: "/risk-analysis-location",
|
|
||||||
meta: {
|
meta: {
|
||||||
icon: <TbLocationPin className="w-6 h-6" />,
|
icon: <MdWater className="w-6 h-6" />,
|
||||||
label: "风险分析定位",
|
label: "事件模拟",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "管网优化分区",
|
name: "爆管模拟",
|
||||||
list: "/network-partition-optimization",
|
list: "/hydraulic-simulation/burst-simulation",
|
||||||
meta: {
|
meta: {
|
||||||
icon: <AiOutlinePartition className="w-6 h-6" />,
|
parent: "Hydraulic Simulation",
|
||||||
label: "管网优化分区",
|
icon: <TbLocationPin className="w-6 h-6" />,
|
||||||
|
label: "爆管模拟",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "爆管侦测",
|
||||||
|
list: "/hydraulic-simulation/burst-detection",
|
||||||
|
meta: {
|
||||||
|
parent: "Hydraulic Simulation",
|
||||||
|
icon: <TbActivity className="w-6 h-6" />,
|
||||||
|
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,13 +1,58 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
const authOptions = {
|
type KeycloakTokenResponse = {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!;
|
||||||
|
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||||
|
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!;
|
||||||
|
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
|
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||||
|
if (!token.refreshToken) {
|
||||||
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: keycloakClientId,
|
||||||
|
client_secret: keycloakClientSecret,
|
||||||
|
refresh_token: token.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(keycloakTokenEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const refreshed = (await response.json()) as KeycloakTokenResponse;
|
||||||
|
|
||||||
|
if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") {
|
||||||
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: refreshed.access_token,
|
||||||
|
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
||||||
|
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const authOptions: NextAuthOptions = {
|
||||||
// Configure one or more authentication providers
|
// Configure one or more authentication providers
|
||||||
providers: [
|
providers: [
|
||||||
KeycloakProvider({
|
KeycloakProvider({
|
||||||
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
clientId: keycloakClientId,
|
||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
clientSecret: keycloakClientSecret,
|
||||||
issuer: process.env.KEYCLOAK_ISSUER!,
|
issuer: keycloakIssuer,
|
||||||
profile(profile) {
|
profile(profile) {
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
@@ -19,6 +64,45 @@ const authOptions = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
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,11 +1,12 @@
|
|||||||
"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 { ThemedTitle } from "@refinedev/mui";
|
import { Title } from "@components/title";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { mutate: login } = useLogin();
|
const { mutate: login } = useLogin();
|
||||||
@@ -25,13 +26,9 @@ export default function Login() {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
>
|
>
|
||||||
<ThemedTitle
|
<Box display="flex" justifyContent="center">
|
||||||
collapsed={false}
|
<Title collapsed={false} />
|
||||||
wrapperStyles={{
|
</Box>
|
||||||
fontSize: "22px",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
style={{ width: "240px" }}
|
style={{ width: "240px" }}
|
||||||
size="large"
|
size="large"
|
||||||
@@ -42,10 +39,12 @@ 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
|
||||||
<img
|
<Image
|
||||||
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>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
import { Box, Paper, Typography, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Inline chart rendered inside a chat message bubble. */
|
||||||
|
/* Accepts structured data produced by the AI tool_call. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface ChatChartSeries {
|
||||||
|
name: string;
|
||||||
|
data: number[];
|
||||||
|
type?: "line" | "bar";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInlineChartProps {
|
||||||
|
title?: string;
|
||||||
|
chart_type?: "line" | "bar" | "pie";
|
||||||
|
x_data?: string[];
|
||||||
|
series?: ChatChartSeries[];
|
||||||
|
y_axis_name?: string;
|
||||||
|
x_axis_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#5470c6",
|
||||||
|
"#91cc75",
|
||||||
|
"#fac858",
|
||||||
|
"#ee6666",
|
||||||
|
"#73c0de",
|
||||||
|
"#3ba272",
|
||||||
|
"#fc8452",
|
||||||
|
"#9a60b4",
|
||||||
|
"#ea7ccc",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||||
|
title,
|
||||||
|
chart_type: chartType = "line",
|
||||||
|
x_data: xData,
|
||||||
|
series = [],
|
||||||
|
y_axis_name: yAxisName,
|
||||||
|
x_axis_name: xAxisName,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const option = useMemo(() => {
|
||||||
|
if (!series.length) return null;
|
||||||
|
|
||||||
|
/* ---------- Pie chart ---------- */
|
||||||
|
if (chartType === "pie") {
|
||||||
|
const pieData =
|
||||||
|
series[0]?.data.map((value, i) => ({
|
||||||
|
name: xData?.[i] ?? `${i}`,
|
||||||
|
value,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: "item" },
|
||||||
|
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pie",
|
||||||
|
radius: ["30%", "60%"],
|
||||||
|
data: pieData,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
color: COLORS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Line / Bar chart ---------- */
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: "axis", confine: true },
|
||||||
|
legend: { top: "top", textStyle: { fontSize: 11 } },
|
||||||
|
grid: {
|
||||||
|
left: "5%",
|
||||||
|
right: "5%",
|
||||||
|
bottom: "12%",
|
||||||
|
top: title ? "18%" : "14%",
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category" as const,
|
||||||
|
boundaryGap: chartType === "bar",
|
||||||
|
data: xData ?? [],
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
rotate: xData && xData.length > 10 ? 30 : 0,
|
||||||
|
},
|
||||||
|
name: xAxisName,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value" as const,
|
||||||
|
scale: true,
|
||||||
|
axisLabel: { fontSize: 10 },
|
||||||
|
name: yAxisName,
|
||||||
|
},
|
||||||
|
dataZoom:
|
||||||
|
xData && xData.length > 20
|
||||||
|
? [{ type: "inside", start: 0, end: 100 }]
|
||||||
|
: undefined,
|
||||||
|
series: series.map((s, i) => {
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
type: (s.type ?? chartType) as string,
|
||||||
|
data: s.data,
|
||||||
|
symbol: chartType === "line" ? "none" : undefined,
|
||||||
|
smooth: chartType === "line",
|
||||||
|
itemStyle: { color },
|
||||||
|
...(chartType === "line"
|
||||||
|
? {
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: alpha(color, 0.3) },
|
||||||
|
{ offset: 1, color: alpha(color, 0.05) },
|
||||||
|
]),
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
color: COLORS,
|
||||||
|
};
|
||||||
|
}, [chartType, xData, series, title, yAxisName, xAxisName]);
|
||||||
|
|
||||||
|
if (!option) {
|
||||||
|
return (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
图表数据为空
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.92),
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ px: 1, pb: 1 }}>
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height: 240, width: "100%" }}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
||||||
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||||
|
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||||
|
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||||
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
|
import {
|
||||||
|
parseAssistantMessageSections,
|
||||||
|
parseContentWithToolCalls,
|
||||||
|
type ContentSegment,
|
||||||
|
} from "./chatMessageSections";
|
||||||
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||||
|
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||||
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||||
|
|
||||||
|
export const TypingIndicator = () => {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ y: 0 }}
|
||||||
|
animate={{ y: [-4, 4, -4] }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: i * 0.15,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Blob = ({
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
delay,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
top: string;
|
||||||
|
left: string;
|
||||||
|
delay: number;
|
||||||
|
}) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }}
|
||||||
|
animate={{
|
||||||
|
scale: [0.8, 1.2, 0.8],
|
||||||
|
opacity: [0.3, 0.5, 0.3],
|
||||||
|
x: [0, 30, 0],
|
||||||
|
y: [0, -30, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
filter: "blur(60px)",
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ChatMessageItemProps = {
|
||||||
|
message: Message;
|
||||||
|
theme: Theme;
|
||||||
|
messageSpeechState: SpeechState;
|
||||||
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatMessageItem = React.memo(
|
||||||
|
({
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
messageSpeechState,
|
||||||
|
onSpeak,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
sseChartParams,
|
||||||
|
}: ChatMessageItemProps) => {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isErrorMessage = Boolean(message.isError);
|
||||||
|
const parsedAssistantSections =
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseAssistantMessageSections(message.content)
|
||||||
|
: null;
|
||||||
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
|
|
||||||
|
const contentSegments: ContentSegment[] =
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseContentWithToolCalls(answerContent).segments
|
||||||
|
: [{ type: "text", content: answerContent }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 350, damping: 25 }}
|
||||||
|
style={{
|
||||||
|
alignSelf: isUser ? "flex-end" : "flex-start",
|
||||||
|
maxWidth: "85%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: isUser ? "row-reverse" : "row",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isUser && (
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
bgcolor: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.12)
|
||||||
|
: alpha(theme.palette.secondary.main, 0.1),
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isErrorMessage ? (
|
||||||
|
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Paper
|
||||||
|
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderBottomRightRadius: isUser ? 4 : 24,
|
||||||
|
borderBottomLeftRadius: !isUser ? 4 : 24,
|
||||||
|
bgcolor: isUser
|
||||||
|
? "primary.main"
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.light, 0.18)
|
||||||
|
: "#fff",
|
||||||
|
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
|
||||||
|
background: isUser
|
||||||
|
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
|
||||||
|
: isErrorMessage
|
||||||
|
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
|
||||||
|
: undefined,
|
||||||
|
border: isErrorMessage
|
||||||
|
? `1px solid ${alpha(theme.palette.error.main, 0.35)}`
|
||||||
|
: "none",
|
||||||
|
boxShadow: isUser
|
||||||
|
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
|
||||||
|
: isErrorMessage
|
||||||
|
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
|
||||||
|
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
|
||||||
|
"--chat-md-text": isUser
|
||||||
|
? alpha("#fff", 0.96)
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.dark
|
||||||
|
: "#1f2937",
|
||||||
|
"--chat-md-heading": isUser
|
||||||
|
? "#fff"
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.dark
|
||||||
|
: "#111827",
|
||||||
|
"--chat-md-link": isUser
|
||||||
|
? "#E3F2FD"
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.main
|
||||||
|
: "#7C3AED",
|
||||||
|
"--chat-md-link-hover": isUser
|
||||||
|
? "#fff"
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.dark
|
||||||
|
: "#6D28D9",
|
||||||
|
"--chat-md-inline-code-bg": isUser
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.08)
|
||||||
|
: "#EEF2FF",
|
||||||
|
"--chat-md-inline-code-border": isUser
|
||||||
|
? alpha("#fff", 0.16)
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.25)
|
||||||
|
: "#CBD5E1",
|
||||||
|
"--chat-md-inline-code-text": isUser
|
||||||
|
? "#fff"
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.dark
|
||||||
|
: "#334155",
|
||||||
|
"--chat-md-pre-bg": isUser
|
||||||
|
? "rgba(11, 18, 32, 0.56)"
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.08)
|
||||||
|
: "#111827",
|
||||||
|
"--chat-md-pre-border": isUser
|
||||||
|
? alpha("#fff", 0.12)
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.3)
|
||||||
|
: "#64748B",
|
||||||
|
"--chat-md-pre-text": isUser
|
||||||
|
? "#F8FAFC"
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.dark
|
||||||
|
: "#E5E7EB",
|
||||||
|
"--chat-md-quote-border": isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.5)
|
||||||
|
: isUser
|
||||||
|
? alpha("#fff", 0.5)
|
||||||
|
: "#7C3AED",
|
||||||
|
"--chat-md-quote-bg": isUser
|
||||||
|
? alpha("#fff", 0.08)
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.06)
|
||||||
|
: "#F5F3FF",
|
||||||
|
"--chat-md-quote-text": isUser
|
||||||
|
? alpha("#fff", 0.9)
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.dark
|
||||||
|
: "#475569",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contentSegments.map((segment, segIdx) => {
|
||||||
|
if (segment.type === "text") {
|
||||||
|
const text = segment.content.trim();
|
||||||
|
if (!text && contentSegments.length > 1) return null;
|
||||||
|
return (
|
||||||
|
<div key={segIdx} className={markdownStyles.markdown}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{text || "..."}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call") {
|
||||||
|
if (segment.toolCall.tool === "chart") {
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
{...(segment.toolCall.params as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.toolCall.tool === "show_chart") {
|
||||||
|
const p = segment.toolCall.params;
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
title={(p.title as string) ?? undefined}
|
||||||
|
chart_type={
|
||||||
|
(p.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
|
}
|
||||||
|
x_data={(p.x_data as string[]) ?? []}
|
||||||
|
series={
|
||||||
|
(p.series as import("./ChatInlineChart").ChatChartSeries[]) ??
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatToolCallBlock
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
toolCall={segment.toolCall}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call_pending") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key="tool-pending"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
正在准备工具调用...
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
{sseChartParams?.map((chart, idx) => (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={`sse-chart-${idx}`}
|
||||||
|
title={(chart.params.title as string) ?? undefined}
|
||||||
|
chart_type={
|
||||||
|
(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
|
}
|
||||||
|
x_data={(chart.params.x_data as string[]) ?? []}
|
||||||
|
series={
|
||||||
|
(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ??
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
{!isUser && !isErrorMessage && isTtsSupported && (
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
|
||||||
|
{messageSpeechState === "idle" && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
||||||
|
aria-label="朗读消息"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
opacity: 0.6,
|
||||||
|
"&:hover": { opacity: 1 },
|
||||||
|
p: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{messageSpeechState === "playing" && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onPause}
|
||||||
|
aria-label="暂停朗读"
|
||||||
|
sx={{ color: "primary.main", p: 0.5 }}
|
||||||
|
>
|
||||||
|
<PauseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onStopSpeech}
|
||||||
|
aria-label="停止朗读"
|
||||||
|
sx={{ color: "error.main", p: 0.5 }}
|
||||||
|
>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{messageSpeechState === "paused" && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onResume}
|
||||||
|
aria-label="继续朗读"
|
||||||
|
sx={{ color: "primary.main", p: 0.5 }}
|
||||||
|
>
|
||||||
|
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onStopSpeech}
|
||||||
|
aria-label="停止朗读"
|
||||||
|
sx={{ color: "error.main", p: 0.5 }}
|
||||||
|
>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatMessageItem.displayName = "ChatMessageItem";
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type Message = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeechState = "idle" | "playing" | "paused";
|
||||||
|
|
||||||
|
export type PersistedChatState = {
|
||||||
|
messages: Message[];
|
||||||
|
conversationId?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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,29 +3,82 @@
|
|||||||
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 } from "@refinedev/core";
|
import { useGetIdentity, useLogout } from "@refinedev/core";
|
||||||
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
|
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
|
||||||
import React, { useContext } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
|
import { ProjectSelector } from "@components/project/ProjectSelector";
|
||||||
|
import { GlobalChatbox } from "@components/chat/GlobalChatbox";
|
||||||
|
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: number;
|
id?: string;
|
||||||
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>
|
||||||
@@ -42,19 +95,56 @@ 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="16px"
|
gap="12px"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
@@ -65,15 +155,80 @@ 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 src={user?.avatar} alt={user?.name} />
|
<Avatar
|
||||||
|
src={user?.avatar}
|
||||||
|
alt={user?.name}
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: `2px solid ${mode === "dark"
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(0,0,0,0.1)"
|
||||||
|
}`,
|
||||||
|
transition: "transform 0.3s ease",
|
||||||
|
".MuiButtonBase-root:hover &": {
|
||||||
|
transform: "rotate(5deg) scale(1.05)",
|
||||||
|
borderColor: "primary.main",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</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 } from "@mui/material";
|
import { Box, Skeleton, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 地图页面骨架屏组件
|
* 地图页面骨架屏组件
|
||||||
@@ -26,7 +26,24 @@ 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",
|
||||||
@@ -34,100 +51,100 @@ export function MapSkeleton() {
|
|||||||
left: 20,
|
left: 20,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1,
|
gap: 1.5,
|
||||||
|
zIndex: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={i}
|
key={i}
|
||||||
variant="rectangular"
|
variant="circular"
|
||||||
width={48}
|
width={40}
|
||||||
height={48}
|
height={40}
|
||||||
animation="wave"
|
animation="wave"
|
||||||
sx={{ borderRadius: 1 }}
|
sx={{ boxShadow: 1 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 右侧控制面板骨架 */}
|
{/* 右侧控制面板骨架 (抽屉式) */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 20,
|
top: 0,
|
||||||
right: 20,
|
right: 0,
|
||||||
width: 320,
|
width: { xs: "100%", sm: 360 },
|
||||||
|
height: "100%",
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
borderRadius: 2,
|
borderLeft: 1,
|
||||||
p: 2,
|
borderColor: "divider",
|
||||||
boxShadow: 3,
|
p: 3,
|
||||||
|
zIndex: 5,
|
||||||
|
display: { xs: "none", md: "flex" },
|
||||||
|
flexDirection: "column",
|
||||||
|
boxShadow: -2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Skeleton width="60%" height={32} animation="wave" sx={{ mb: 2 }} />
|
<Skeleton variant="text" width="60%" height={40} sx={{ mb: 3 }} />
|
||||||
<Skeleton width="100%" height={24} animation="wave" sx={{ mb: 1 }} />
|
|
||||||
<Skeleton width="80%" height={24} animation="wave" sx={{ mb: 1 }} />
|
{/* 面板内容区块 */}
|
||||||
<Skeleton width="90%" height={24} animation="wave" sx={{ mb: 2 }} />
|
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||||||
<Skeleton
|
<Skeleton variant="rectangular" width="100%" height={100} sx={{ mb: 2, borderRadius: 1 }} />
|
||||||
variant="rectangular"
|
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 1 }} />
|
||||||
width="100%"
|
<Skeleton variant="rectangular" width="100%" height={180} sx={{ mb: 2, borderRadius: 1 }} />
|
||||||
height={200}
|
|
||||||
animation="wave"
|
<Box sx={{ mt: 2 }}>
|
||||||
sx={{ borderRadius: 1 }}
|
{[1, 2, 3].map((i) => (
|
||||||
/>
|
<Box key={i} sx={{ display: "flex", gap: 2, mb: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={36} height={36} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="80%" />
|
||||||
|
<Skeleton variant="text" width="50%" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 底部时间轴骨架 */}
|
{/* 底部时间轴/控制条骨架 */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 20,
|
bottom: 30,
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
width: "60%",
|
width: { xs: "90%", md: "60%" },
|
||||||
|
height: 64,
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
borderRadius: 2,
|
borderRadius: 4,
|
||||||
p: 2,
|
|
||||||
boxShadow: 3,
|
boxShadow: 3,
|
||||||
|
p: 2,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
zIndex: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Skeleton width="100%" height={40} animation="wave" />
|
<Skeleton variant="circular" width={32} height={32} />
|
||||||
|
<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: 100,
|
bottom: 110,
|
||||||
right: 20,
|
right: { xs: 20, md: 380 }, // Adjust if drawer is open
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
zIndex: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Skeleton
|
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||||
variant="rectangular"
|
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback } from "react";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
FormControl,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
|
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||||
|
import { useNotification } from "@refinedev/core";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import "dayjs/locale/zh-cn";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { NETWORK_NAME, config } from "@config/config";
|
||||||
|
import { BurstDetectionResult } from "./types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onResult: (result: BurstDetectionResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchemeItem {
|
||||||
|
scheme_id: number;
|
||||||
|
scheme_name: string;
|
||||||
|
scheme_type: string;
|
||||||
|
create_time: string;
|
||||||
|
scheme_start_time: string;
|
||||||
|
scheme_detail?: {
|
||||||
|
modify_total_duration: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||||
|
const { open } = useNotification();
|
||||||
|
const [schemeName, setSchemeName] = useState(`Burst_Detection_${Date.now()}`);
|
||||||
|
const [dataSource, setDataSource] = useState<"monitoring" | "simulation">("monitoring");
|
||||||
|
const [schemes, setSchemes] = useState<SchemeItem[]>([]);
|
||||||
|
const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">("");
|
||||||
|
const [schemeLoading, setSchemeLoading] = useState(false);
|
||||||
|
const [scadaStart, setScadaStart] = useState<Dayjs | null>(dayjs().subtract(3, "day"));
|
||||||
|
const [scadaEnd, setScadaEnd] = useState<Dayjs | null>(dayjs());
|
||||||
|
const [mu, setMu] = useState<number>(100);
|
||||||
|
const [pointsPerDay, setPointsPerDay] = useState<number>(96);
|
||||||
|
const [nEstimators, setNEstimators] = useState<number>(50);
|
||||||
|
const [contaminationInput, setContaminationInput] = useState<string>("auto");
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const isSimulationMode = dataSource === "simulation";
|
||||||
|
|
||||||
|
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||||||
|
const start = dayjs(scheme.scheme_start_time);
|
||||||
|
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
|
||||||
|
const end = start.add(durationSeconds, "second");
|
||||||
|
|
||||||
|
setScadaStart(start);
|
||||||
|
setScadaEnd(end);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSchemes = useCallback(
|
||||||
|
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
|
||||||
|
if (schemeLoading || (!force && schemes.length > 0)) return;
|
||||||
|
|
||||||
|
setSchemeLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, {
|
||||||
|
params: { network: NETWORK_NAME },
|
||||||
|
});
|
||||||
|
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||||
|
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||||
|
);
|
||||||
|
|
||||||
|
setSchemes(burstSchemes);
|
||||||
|
|
||||||
|
if (selectedSchemeId) {
|
||||||
|
const matchedScheme = burstSchemes.find(
|
||||||
|
(scheme) => scheme.scheme_id === selectedSchemeId,
|
||||||
|
);
|
||||||
|
if (matchedScheme) {
|
||||||
|
applySchemeTimeRange(matchedScheme);
|
||||||
|
} else {
|
||||||
|
setSelectedSchemeId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
open?.({
|
||||||
|
type: "success",
|
||||||
|
message: "方案列表已刷新",
|
||||||
|
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "刷新方案失败",
|
||||||
|
description:
|
||||||
|
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSchemeLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
|
||||||
|
setDataSource(value);
|
||||||
|
if (value === "simulation") {
|
||||||
|
void fetchSchemes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchemeSelect = (schemeId: number) => {
|
||||||
|
setSelectedSchemeId(schemeId);
|
||||||
|
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
||||||
|
if (scheme) {
|
||||||
|
applySchemeTimeRange(scheme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeWindowValid = useMemo(() => {
|
||||||
|
if (!scadaStart || !scadaEnd) return false;
|
||||||
|
return scadaEnd.diff(scadaStart, "day", true) >= 2;
|
||||||
|
}, [scadaEnd, scadaStart]);
|
||||||
|
|
||||||
|
const contaminationValue = useMemo(() => {
|
||||||
|
const normalized = contaminationInput.trim().toLowerCase();
|
||||||
|
if (!normalized || normalized === "auto") {
|
||||||
|
return "auto" as const;
|
||||||
|
}
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}, [contaminationInput]);
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
Boolean(scadaStart && scadaEnd) &&
|
||||||
|
timeWindowValid &&
|
||||||
|
Number.isFinite(mu) &&
|
||||||
|
mu > 0 &&
|
||||||
|
Number.isFinite(pointsPerDay) &&
|
||||||
|
pointsPerDay > 0 &&
|
||||||
|
Number.isFinite(nEstimators) &&
|
||||||
|
nEstimators > 0 &&
|
||||||
|
contaminationValue !== null &&
|
||||||
|
(dataSource !== "simulation" || Boolean(selectedSchemeId));
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "参数不完整",
|
||||||
|
description: "请检查时间范围(至少2天)和高级参数是否填写正确。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
open?.({
|
||||||
|
key: "burst-detection-analysis-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;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { zhCN } from "@mui/x-data-grid/locales";
|
||||||
|
import {
|
||||||
|
FormatListBulleted,
|
||||||
|
InfoOutlined as InfoOutlinedIcon,
|
||||||
|
Room as RoomIcon,
|
||||||
|
ShowChart as ShowChartIcon,
|
||||||
|
CheckCircleOutline as CheckCircleIcon,
|
||||||
|
ErrorOutline as ErrorOutlineIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
|
import { GeoJSON } from "ol/format";
|
||||||
|
import Feature from "ol/Feature";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import { Circle, Fill, Stroke, Style } from "ol/style";
|
||||||
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
|
import { BurstDetectionResult, BurstDetectionRow } from "./types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: BurstDetectionResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
tone: "blue" | "orange" | "purple" | "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneStyles: Record<
|
||||||
|
MetricCardProps["tone"],
|
||||||
|
{ bg: string; border: string; text: string; darkText: string }
|
||||||
|
> = {
|
||||||
|
blue: {
|
||||||
|
bg: "from-blue-50 to-blue-100",
|
||||||
|
border: "border-blue-200",
|
||||||
|
text: "text-blue-700",
|
||||||
|
darkText: "text-blue-900",
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
bg: "from-orange-50 to-orange-100",
|
||||||
|
border: "border-orange-200",
|
||||||
|
text: "text-orange-700",
|
||||||
|
darkText: "text-orange-900",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
bg: "from-purple-50 to-purple-100",
|
||||||
|
border: "border-purple-200",
|
||||||
|
text: "text-purple-700",
|
||||||
|
darkText: "text-purple-900",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
bg: "from-green-50 to-green-100",
|
||||||
|
border: "border-green-200",
|
||||||
|
text: "text-green-700",
|
||||||
|
darkText: "text-green-900",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
|
||||||
|
const style = toneStyles[tone];
|
||||||
|
return (
|
||||||
|
<Box className={`rounded-lg border bg-gradient-to-br p-3 shadow-sm ${style.bg} ${style.border}`}>
|
||||||
|
<Typography variant="caption" className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${style.text}`}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className={`font-bold ${style.darkText}`}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
{hint ? (
|
||||||
|
<Typography variant="caption" className={`mt-0.5 block text-xs opacity-80 ${style.text}`}>
|
||||||
|
{hint}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyState = () => (
|
||||||
|
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
|
||||||
|
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
||||||
|
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
|
||||||
|
等待侦测结果
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className="max-w-xs text-gray-500">
|
||||||
|
提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const getScoreLevel = (score: number) => {
|
||||||
|
if (score <= -0.6) return { label: "高风险", color: "error" as const };
|
||||||
|
if (score <= -0.2) return { label: "需关注", color: "warning" as const };
|
||||||
|
return { label: "正常", color: "success" as const };
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
|
||||||
|
|
||||||
|
const DetectionResults: React.FC<Props> = ({ result }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
|
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const layer = new VectorLayer({
|
||||||
|
source: new VectorSource(),
|
||||||
|
style: new Style({
|
||||||
|
stroke: new Stroke({ color: "#ef4444", width: 4 }),
|
||||||
|
image: new Circle({
|
||||||
|
radius: 7,
|
||||||
|
fill: new Fill({ color: "#ef4444" }),
|
||||||
|
stroke: new Stroke({ color: "#fff", width: 2 }),
|
||||||
|
}),
|
||||||
|
zIndex: 999,
|
||||||
|
}),
|
||||||
|
properties: {
|
||||||
|
name: "爆管侦测高亮",
|
||||||
|
value: "burst_detection_highlight",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer(layer);
|
||||||
|
highlightLayerRef.current = layer;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
highlightLayerRef.current = null;
|
||||||
|
map.removeLayer(layer);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = highlightLayerRef.current?.getSource();
|
||||||
|
if (!source) return;
|
||||||
|
source.clear();
|
||||||
|
highlightFeatures.forEach((feature) => source.addFeature(feature));
|
||||||
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
|
const defaultSelectedDay = useMemo(
|
||||||
|
() =>
|
||||||
|
result?.summary?.most_anomalous_day ??
|
||||||
|
result?.summary?.latest_day?.Day ??
|
||||||
|
result?.rows[0]?.Day ??
|
||||||
|
null,
|
||||||
|
[result],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSelectedDay = selectedDay ?? defaultSelectedDay;
|
||||||
|
|
||||||
|
const selectedRow = useMemo<BurstDetectionRow | null>(() => {
|
||||||
|
if (!result || activeSelectedDay === null) return null;
|
||||||
|
return result.rows.find((row) => row.Day === activeSelectedDay) ?? null;
|
||||||
|
}, [activeSelectedDay, result]);
|
||||||
|
|
||||||
|
const scoreSeries = useMemo(
|
||||||
|
() =>
|
||||||
|
result?.rows.map((row) => ({
|
||||||
|
value: [row.Day, Number(row.Score.toFixed(4))],
|
||||||
|
itemStyle: {
|
||||||
|
color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981",
|
||||||
|
},
|
||||||
|
})) ?? [],
|
||||||
|
[result],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rankingSeries = useMemo(
|
||||||
|
() =>
|
||||||
|
[...(result?.summary?.latest_sensor_rankings ?? [])]
|
||||||
|
.sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value)
|
||||||
|
.map((item) => ({
|
||||||
|
name: item.sensor_node,
|
||||||
|
value: Number(item.latest_high_frequency_value.toFixed(4)),
|
||||||
|
})),
|
||||||
|
[result],
|
||||||
|
);
|
||||||
|
|
||||||
|
const locateSensors = async (sensorIds: string[]) => {
|
||||||
|
if (!map || sensorIds.length === 0) return;
|
||||||
|
|
||||||
|
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
|
||||||
|
if (features.length === 0) {
|
||||||
|
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
|
||||||
|
}
|
||||||
|
if (features.length === 0) return;
|
||||||
|
|
||||||
|
setHighlightFeatures(features);
|
||||||
|
|
||||||
|
const geojsonFormat = new GeoJSON();
|
||||||
|
const geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature));
|
||||||
|
// @ts-ignore turf typing with ol geojson objects
|
||||||
|
const extent = bbox(featureCollection(geojsonFeatures));
|
||||||
|
map.getView().fit(extent, {
|
||||||
|
maxZoom: 18,
|
||||||
|
duration: 1000,
|
||||||
|
padding: [100, 100, 100, 100],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return <EmptyState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestDay = result.summary?.latest_day;
|
||||||
|
const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0);
|
||||||
|
const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null;
|
||||||
|
const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0);
|
||||||
|
const isBurstDetected = result.summary.burst_detected;
|
||||||
|
|
||||||
|
const chartOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
formatter: (params: Array<{ data: { value: [number, number] } }>) => {
|
||||||
|
const point = params[0]?.data?.value;
|
||||||
|
if (!point) return "-";
|
||||||
|
return `侦测日第 ${point[0]} 天<br/>异常分数:${point[1]}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { top: 30, left: 40, right: 20, bottom: 35 },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
name: "侦测日",
|
||||||
|
data: result.rows.map((row) => row.Day),
|
||||||
|
axisLabel: { fontSize: 10 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
name: "异常分数",
|
||||||
|
axisLabel: { fontSize: 10 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
data: scoreSeries,
|
||||||
|
lineStyle: { color: "#2563eb", width: 2 },
|
||||||
|
markLine: {
|
||||||
|
symbol: "none",
|
||||||
|
lineStyle: { type: "dashed", color: "#94a3b8" },
|
||||||
|
data: [{ yAxis: 0 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rankingOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
axisPointer: { type: "shadow" },
|
||||||
|
},
|
||||||
|
grid: { top: 20, left: 70, right: 20, bottom: 20 },
|
||||||
|
xAxis: { type: "value", axisLabel: { fontSize: 10 } },
|
||||||
|
yAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: rankingSeries.map((item) => item.name),
|
||||||
|
axisLabel: { fontSize: 10 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "bar",
|
||||||
|
data: rankingSeries.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
itemStyle: {
|
||||||
|
color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
barWidth: 14,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "Day",
|
||||||
|
headerName: "侦测日",
|
||||||
|
width: 96,
|
||||||
|
valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "Score",
|
||||||
|
headerName: "异常分数",
|
||||||
|
width: 120,
|
||||||
|
valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "IsBurst",
|
||||||
|
headerName: "判定结果",
|
||||||
|
width: 120,
|
||||||
|
renderCell: ({ value }) => {
|
||||||
|
const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const };
|
||||||
|
return <Chip size="small" label={level.label} color={level.color} variant="outlined" />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="h-full overflow-auto p-1">
|
||||||
|
<Box className="mb-4 space-y-3">
|
||||||
|
{/* Status Banner */}
|
||||||
|
<Box
|
||||||
|
className={`rounded-lg px-4 py-3 flex items-center gap-3 border ${isBurstDetected
|
||||||
|
? "bg-red-50 border-red-100 text-red-900"
|
||||||
|
: "bg-green-50 border-green-100 text-green-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isBurstDetected ? (
|
||||||
|
<ErrorOutlineIcon className="text-red-600" />
|
||||||
|
) : (
|
||||||
|
<CheckCircleIcon className="text-green-600" />
|
||||||
|
)}
|
||||||
|
<Box className="flex-1">
|
||||||
|
<Typography variant="subtitle2" className="font-bold">
|
||||||
|
{isBurstDetected
|
||||||
|
? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
|
||||||
|
: "未侦测到爆管异常"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" className="opacity-80">
|
||||||
|
{isBurstDetected
|
||||||
|
? "建议检查异常日期的压力波动情况"
|
||||||
|
: "当前时间窗口内数据特征平稳,符合历史模式"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Box className="flex items-center justify-between px-1">
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Box className="h-4 w-1 rounded-full bg-blue-600" />
|
||||||
|
<Typography variant="h6" className="truncate font-bold text-gray-900" sx={{ fontSize: "1.1rem" }}>
|
||||||
|
{result.scheme_name || "爆管侦测结果"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
{result.username ? (
|
||||||
|
<Chip
|
||||||
|
label={result.username}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
backgroundColor: "#f3f4f6",
|
||||||
|
color: "#4b5563",
|
||||||
|
border: "none",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RoomIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5))
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: "0 8px",
|
||||||
|
borderColor: "#bfdbfe",
|
||||||
|
color: "#2563eb",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
定位
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Configuration Summary */}
|
||||||
|
<Box className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
|
||||||
|
<Box className="flex items-center gap-1.5">
|
||||||
|
<Box className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||||
|
<span className="font-medium text-gray-700">时间窗口:</span>
|
||||||
|
<span className="font-mono text-gray-600">
|
||||||
|
{formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
<Box className="flex items-center gap-1.5">
|
||||||
|
<Box className="h-1.5 w-1.5 rounded-full bg-purple-400" />
|
||||||
|
<span className="font-medium text-gray-700">数据来源:</span>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{(() => {
|
||||||
|
const ds = result.data_source;
|
||||||
|
const os = result.observed_source;
|
||||||
|
if (ds === "simulation") return "模拟数据";
|
||||||
|
if (ds === "monitoring") return "监测数据";
|
||||||
|
if (os === "simulation_scheme_timerange") return "模拟数据";
|
||||||
|
if (os === "backend_timerange") return "监测数据";
|
||||||
|
return os || "-";
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Metrics Grid */}
|
||||||
|
<Box className="grid grid-cols-2 gap-3">
|
||||||
|
<MetricCard
|
||||||
|
label="异常天数"
|
||||||
|
value={`${result.summary.anomaly_day_count} / ${result.day_count}`}
|
||||||
|
hint={`异常日:${result.summary.anomaly_days.join(", ") || "无"}`}
|
||||||
|
tone={result.summary.anomaly_day_count > 0 ? "orange" : "green"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="最异常日"
|
||||||
|
value={
|
||||||
|
result.summary.burst_detected && result.summary.most_anomalous_day
|
||||||
|
? `第 ${result.summary.most_anomalous_day} 天`
|
||||||
|
: "无"
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
result.summary.burst_detected && mostAnomalousRow
|
||||||
|
? `分数 ${mostAnomalousRow.Score.toFixed(4)} · ${mostAnomalousLevel.label}`
|
||||||
|
: "-"
|
||||||
|
}
|
||||||
|
tone="purple"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="最新状态"
|
||||||
|
value={latestLevel.label}
|
||||||
|
hint={latestDay ? `第 ${latestDay.Day} 天 · 分数 ${latestDay.Score.toFixed(4)}` : "-"}
|
||||||
|
tone={latestLevel.color === "success" ? "green" : "orange"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="测点 / 样本"
|
||||||
|
value={`${result.sensor_nodes.length} / ${result.sample_count}`}
|
||||||
|
hint={`每日采样点数:${result.points_per_day}`}
|
||||||
|
tone="blue"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Score Trend Chart */}
|
||||||
|
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||||
|
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<ShowChartIcon className="h-5 w-5 text-blue-600" />
|
||||||
|
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||||
|
异常分数趋势
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="分数越小越异常,0 以下通常意味着更值得关注。">
|
||||||
|
<InfoOutlinedIcon fontSize="small" className="text-gray-400" />
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ height: 250, px: 1.5, py: 1 }}>
|
||||||
|
<ReactECharts
|
||||||
|
option={chartOption}
|
||||||
|
style={{ height: "100%", width: "100%" }}
|
||||||
|
onEvents={{
|
||||||
|
click: (params: { data?: { value?: [number, number] } }) => {
|
||||||
|
const day = params?.data?.value?.[0];
|
||||||
|
if (typeof day === "number") {
|
||||||
|
setSelectedDay(day);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Selected Day Interpretation */}
|
||||||
|
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||||
|
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||||
|
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||||
|
选中日解读
|
||||||
|
</Typography>
|
||||||
|
{selectedRow ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`第 ${selectedRow.Day} 天`}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
||||||
|
color: "#2563eb",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
{selectedRow ? (
|
||||||
|
<Box className="space-y-3 px-4 py-3">
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<Chip
|
||||||
|
label={getScoreLevel(selectedRow.Score).label}
|
||||||
|
color={getScoreLevel(selectedRow.Score).color}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" className="text-gray-700">
|
||||||
|
异常分数:<span className="font-semibold">{selectedRow.Score.toFixed(4)}</span>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className="text-gray-700">
|
||||||
|
模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1)" : "正常日(Prediction = 1)"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" className="text-gray-700">
|
||||||
|
解读建议:
|
||||||
|
{selectedRow.Score <= -0.6
|
||||||
|
? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
|
||||||
|
: selectedRow.Score <= -0.2
|
||||||
|
? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
|
||||||
|
: "未见明显异常,可作为基线日参考。"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" className="px-4 py-3 text-gray-500">
|
||||||
|
请在趋势图或表格中选择一天查看详细解释。
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box> */}
|
||||||
|
|
||||||
|
{/* Latest Sensor Rankings */}
|
||||||
|
{/* <Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||||
|
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||||
|
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||||
|
最新测点高频特征排名
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" className="text-gray-500">
|
||||||
|
仅展示最新一天
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ height: 260, px: 1.5, py: 1 }}>
|
||||||
|
<ReactECharts option={rankingOption} style={{ height: "100%", width: "100%" }} />
|
||||||
|
</Box>
|
||||||
|
<Box className="flex flex-wrap gap-2 border-t border-gray-100 px-4 py-3">
|
||||||
|
{result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.sensor_node}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => locateSensors([item.sensor_node])}
|
||||||
|
sx={{
|
||||||
|
borderColor: "#bfdbfe",
|
||||||
|
color: "#2563eb",
|
||||||
|
"&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.sensor_node}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box> */}
|
||||||
|
|
||||||
|
{/* Results Table */}
|
||||||
|
<Box className="mb-4 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm">
|
||||||
|
<Box className="flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3">
|
||||||
|
<Box className="flex items-center gap-2">
|
||||||
|
<FormatListBulleted className="h-5 w-5 text-blue-600" />
|
||||||
|
<Typography variant="subtitle1" className="font-bold text-gray-800">
|
||||||
|
结果表格
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${rows.length} 条`}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
backgroundColor: "rgba(37, 99, 235, 0.08)",
|
||||||
|
color: "#2563eb",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ height: 320, px: 1, py: 1 }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
columnBufferPx={100}
|
||||||
|
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
|
||||||
|
initialState={{
|
||||||
|
pagination: { paginationModel: { pageSize: 50, page: 0 } },
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[50]}
|
||||||
|
hideFooterSelectedRowCount
|
||||||
|
sx={{
|
||||||
|
border: "none",
|
||||||
|
"& .MuiDataGrid-cell": { borderColor: "#f0f0f0" },
|
||||||
|
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fafafa" },
|
||||||
|
"& .MuiDataGrid-row:hover": { backgroundColor: "#f8fafc" },
|
||||||
|
// Hide the rows per page selector since it's fixed to 50
|
||||||
|
"& .MuiTablePagination-selectLabel": { display: "none" },
|
||||||
|
"& .MuiTablePagination-input": { display: "none" },
|
||||||
|
}}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
onRowClick={(params) => setSelectedDay(Number(params.row.Day))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetectionResults;
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export interface BurstDetectionRow {
|
||||||
|
Day: number;
|
||||||
|
Score: number;
|
||||||
|
Prediction: number;
|
||||||
|
IsBurst: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurstDetectionSensorRanking {
|
||||||
|
sensor_node: string;
|
||||||
|
latest_high_frequency_value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurstDetectionSummary {
|
||||||
|
burst_detected: boolean;
|
||||||
|
latest_day: BurstDetectionRow;
|
||||||
|
most_anomalous_day: number;
|
||||||
|
anomaly_days: number[];
|
||||||
|
anomaly_day_count: number;
|
||||||
|
latest_sensor_rankings: BurstDetectionSensorRanking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurstDetectionAlgorithmParams {
|
||||||
|
mu?: number;
|
||||||
|
points_per_day?: number;
|
||||||
|
iforest_params?: {
|
||||||
|
n_estimators?: number;
|
||||||
|
contamination?: number | "auto";
|
||||||
|
random_state?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurstDetectionResult {
|
||||||
|
network: string;
|
||||||
|
sensor_nodes: string[];
|
||||||
|
observed_source: string;
|
||||||
|
sample_count: number;
|
||||||
|
points_per_day: number;
|
||||||
|
day_count: number;
|
||||||
|
rows: BurstDetectionRow[];
|
||||||
|
summary: BurstDetectionSummary;
|
||||||
|
scada_window?: {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
};
|
||||||
|
scheme_name?: string;
|
||||||
|
username?: string;
|
||||||
|
create_time?: string;
|
||||||
|
data_source?: "monitoring" | "simulation";
|
||||||
|
simulation_scheme?: {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
algorithm_params?: BurstDetectionAlgorithmParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurstDetectionSchemeDetail {
|
||||||
|
network?: string;
|
||||||
|
sensor_nodes?: string[];
|
||||||
|
observed_source?: string;
|
||||||
|
scada_window?: {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
};
|
||||||
|
algorithm_params?: BurstDetectionAlgorithmParams;
|
||||||
|
result_summary?: BurstDetectionSummary;
|
||||||
|
result_payload?: BurstDetectionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurstDetectionSchemeRecord {
|
||||||
|
scheme_id: number;
|
||||||
|
scheme_name: string;
|
||||||
|
scheme_type?: string;
|
||||||
|
create_time: string;
|
||||||
|
scheme_start_time?: string;
|
||||||
|
username?: string;
|
||||||
|
scheme_detail?: BurstDetectionSchemeDetail;
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,733 +0,0 @@
|
|||||||
"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;
|
|
||||||
+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 "@app/OlMap/MapComponent";
|
import { useMap } from "@components/olmap/core/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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
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,6 +61,39 @@ 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;
|
||||||
@@ -137,7 +170,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
map.removeLayer(highlightLayer);
|
map.removeLayer(highlightLayer);
|
||||||
map.un("click", handleMapClickSelectFeatures);
|
map.un("click", handleMapClickSelectFeatures);
|
||||||
};
|
};
|
||||||
}, [map]);
|
}, [map, handleMapClickSelectFeatures]);
|
||||||
// 高亮要素的函数
|
// 高亮要素的函数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightLayer) {
|
if (!highlightLayer) {
|
||||||
@@ -155,7 +188,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [highlightFeatures]);
|
}, [highlightFeatures, highlightLayer]);
|
||||||
|
|
||||||
// 同步高亮要素和爆管点信息
|
// 同步高亮要素和爆管点信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,42 +218,6 @@ 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;
|
||||||
@@ -283,8 +280,11 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, {
|
await api.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">
|
<Typography className="flex-shrink-0 text-sm pl-1">
|
||||||
{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">
|
||||||
+5
-84
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -22,13 +22,9 @@ 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 ContaminantAnalysisParameters from "../ContaminantSimulation/AnalysisParameters";
|
import { api } from "@/lib/api";
|
||||||
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 {
|
||||||
@@ -56,29 +52,17 @@ 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);
|
||||||
@@ -99,19 +83,9 @@ 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 axios.get(
|
const response = await api.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);
|
||||||
@@ -126,15 +100,8 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAnalyzePipe = (pipeIds: string[]) => {
|
|
||||||
setSelectedPipeIds(pipeIds);
|
|
||||||
setValveAnalysisTriggered(true);
|
|
||||||
setCurrentTab(3);
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawerWidth = 520;
|
const drawerWidth = 520;
|
||||||
const isBurstMode = panelMode === "burst";
|
const panelTitle = "爆管分析";
|
||||||
const panelTitle = isBurstMode ? "爆管分析" : "水质模拟";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -210,32 +177,6 @@ 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}
|
||||||
@@ -271,63 +212,43 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
<Tab
|
<Tab
|
||||||
icon={<MyLocationIcon fontSize="small" />}
|
icon={<MyLocationIcon fontSize="small" />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
label={isBurstMode ? "定位结果" : "模拟结果"}
|
label="定位结果"
|
||||||
/>
|
/>
|
||||||
{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>
|
||||||
</>
|
</>
|
||||||
+9
-50
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -11,10 +11,9 @@ 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 "@app/OlMap/MapComponent";
|
import { useMap } from "@components/olmap/core/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";
|
||||||
@@ -33,18 +32,16 @@ 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 [highlightLayer, setHighlightLayer] =
|
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
@@ -147,19 +144,17 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.addLayer(highlightLayer);
|
map.addLayer(highlightLayer);
|
||||||
setHighlightLayer(highlightLayer);
|
highlightLayerRef.current = highlightLayer;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
highlightLayerRef.current = null;
|
||||||
map.removeLayer(highlightLayer);
|
map.removeLayer(highlightLayer);
|
||||||
};
|
};
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
// 高亮要素的函数
|
// 高亮要素的函数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightLayer) {
|
const source = highlightLayerRef.current?.getSource();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const source = highlightLayer.getSource();
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,7 +166,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [highlightFeatures, highlightLayer]);
|
}, [highlightFeatures]);
|
||||||
|
|
||||||
// 取第一条记录或空对象
|
// 取第一条记录或空对象
|
||||||
const result = results.length > 0 ? results[0] : null;
|
const result = results.length > 0 ? results[0] : null;
|
||||||
@@ -309,7 +304,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
|||||||
sx={{ fontSize: "0.875rem" }}
|
sx={{ fontSize: "0.875rem" }}
|
||||||
>
|
>
|
||||||
{result.leakage !== null
|
{result.leakage !== null
|
||||||
? `${result.leakage.toFixed(2)} m³/h`
|
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -349,23 +344,6 @@ 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"
|
||||||
@@ -404,25 +382,6 @@ 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"
|
||||||
+8
-9
@@ -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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
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 "@app/OlMap/MapComponent";
|
import { useData, useMap } from "@components/olmap/core/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 "@app/OlMap/Controls/Timeline";
|
import Timeline from "@components/olmap/core/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 axios.get(
|
const response = await api.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,8 +122,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSchemes(
|
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
||||||
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,
|
||||||
@@ -131,8 +130,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?.({
|
||||||
@@ -299,7 +298,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [highlightFeatures]);
|
}, [highlightFeatures, highlightLayer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useMap } from "@app/OlMap/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import 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,6 +59,32 @@ 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;
|
||||||
|
|
||||||
@@ -106,7 +132,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
map.removeLayer(layer);
|
map.removeLayer(layer);
|
||||||
map.un("click", handleMapClickSelectFeatures);
|
map.un("click", handleMapClickSelectFeatures);
|
||||||
};
|
};
|
||||||
}, [map]);
|
}, [map, handleMapClickSelectFeatures]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightLayer) return;
|
if (!highlightLayer) return;
|
||||||
@@ -118,32 +144,6 @@ 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,6 +175,10 @@ 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,
|
||||||
@@ -185,7 +189,7 @@ const AnalysisParameters: React.FC = () => {
|
|||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
await axios.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
|
await api.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,7 +280,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 text-sm">
|
<Typography className="flex-shrink-0 pl-1 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">
|
||||||
@@ -373,7 +377,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 名称"
|
placeholder="可选,输入 pattern 名称,默认为 CONSTANT"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Box, Typography } from "@mui/material";
|
|
||||||
|
|
||||||
interface ResultsPanelProps {
|
|
||||||
schemeName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultsPanel: React.FC<ResultsPanelProps> = ({ schemeName }) => {
|
|
||||||
return (
|
|
||||||
<Box className="flex flex-col h-full">
|
|
||||||
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200 p-5">
|
|
||||||
<Typography variant="h6" className="font-semibold text-gray-900">
|
|
||||||
水质模拟结果
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" className="text-gray-600 mt-2">
|
|
||||||
请在下方时间轴查看各时刻的水质分布。
|
|
||||||
</Typography>
|
|
||||||
{schemeName && (
|
|
||||||
<Typography variant="caption" className="text-gray-500 mt-4 block">
|
|
||||||
当前方案:{schemeName}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ResultsPanel;
|
|
||||||
@@ -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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
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 "@app/OlMap/MapComponent";
|
import { useData, useMap } from "@components/olmap/core/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 "@app/OlMap/Controls/Timeline";
|
import Timeline from "@components/olmap/core/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 axios.get(
|
const response = await api.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,8 +195,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSchemes(
|
const nextSchemes = filteredResults.map((item: ContaminantSchemaItem) => ({
|
||||||
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,
|
||||||
@@ -204,8 +203,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?.({
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export interface LeakageRow {
|
||||||
|
Area: string;
|
||||||
|
LeakageRatioRaw: number;
|
||||||
|
LeakageRatio: number;
|
||||||
|
LeakageFlow_m3_per_s: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeakageSchemeRecord {
|
||||||
|
scheme_id: number;
|
||||||
|
scheme_name: string;
|
||||||
|
scheme_type: string;
|
||||||
|
username: string;
|
||||||
|
create_time: string;
|
||||||
|
scheme_start_time: string;
|
||||||
|
scheme_detail: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeakageResultDetail {
|
||||||
|
scheme_name?: string;
|
||||||
|
network?: string;
|
||||||
|
sensor_nodes: string[];
|
||||||
|
rows: LeakageRow[];
|
||||||
|
node_area_map: Record<string, string>;
|
||||||
|
areas: Array<{
|
||||||
|
area_id: string;
|
||||||
|
node_count: number;
|
||||||
|
node_ids: string[];
|
||||||
|
sensor_nodes: string[];
|
||||||
|
}>;
|
||||||
|
drawing_payload: {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
node_visual_payload?: {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
scheme_detail?: {
|
||||||
|
algorithm_params?: {
|
||||||
|
q_sum?: number;
|
||||||
|
q_sum_unit?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
result_summary?: {
|
||||||
|
area_count?: number;
|
||||||
|
max_leakage?: number;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
scheme_start_time?: string;
|
||||||
|
create_time?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const AREA_COLORS = [
|
||||||
|
"#2563eb",
|
||||||
|
"#7c3aed",
|
||||||
|
"#0891b2",
|
||||||
|
"#16a34a",
|
||||||
|
"#ca8a04",
|
||||||
|
"#dc2626",
|
||||||
|
"#ea580c",
|
||||||
|
"#0f766e",
|
||||||
|
"#4338ca",
|
||||||
|
"#be123c",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getAreaColor = (areaId: string | number | undefined) => {
|
||||||
|
const text = String(areaId ?? "");
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return AREA_COLORS[hash % AREA_COLORS.length];
|
||||||
|
};
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
Divider,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Close as CloseIcon } from "@mui/icons-material";
|
||||||
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
|
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||||
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
|
import "dayjs/locale/zh-cn";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import { useMap } from "@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;
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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 } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } 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 { useMap } from "../../../app/OlMap/MapComponent";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import { useHealthRisk } from "./HealthRiskContext";
|
import { useHealthRisk } from "./HealthRiskContext";
|
||||||
import {
|
import {
|
||||||
PredictionResult,
|
PredictionResult,
|
||||||
@@ -62,10 +62,6 @@ interface TimelineProps {
|
|||||||
const Timeline: React.FC<TimelineProps> = ({
|
const Timeline: React.FC<TimelineProps> = ({
|
||||||
disableDateSelection = false,
|
disableDateSelection = false,
|
||||||
}) => {
|
}) => {
|
||||||
const data = useData();
|
|
||||||
if (!data) {
|
|
||||||
return <div>Loading...</div>; // 或其他占位符
|
|
||||||
}
|
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const {
|
const {
|
||||||
predictionResults,
|
predictionResults,
|
||||||
@@ -78,7 +74,6 @@ 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());
|
||||||
@@ -122,7 +117,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
setCurrentYear(value);
|
setCurrentYear(value);
|
||||||
}, 500); // 500ms 防抖延迟
|
}, 500); // 500ms 防抖延迟
|
||||||
},
|
},
|
||||||
[minTime, maxTime],
|
[minTime, maxTime, setCurrentYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 播放控制
|
// 播放控制
|
||||||
@@ -138,7 +133,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
});
|
});
|
||||||
}, playInterval);
|
}, playInterval);
|
||||||
}
|
}
|
||||||
}, [isPlaying, playInterval]);
|
}, [isPlaying, playInterval, maxTime, minTime, setCurrentYear]);
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -177,7 +172,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]);
|
}, [minTime, maxTime, setCurrentYear]);
|
||||||
|
|
||||||
const handleStepForward = useCallback(() => {
|
const handleStepForward = useCallback(() => {
|
||||||
setCurrentYear((prev: number) => {
|
setCurrentYear((prev: number) => {
|
||||||
@@ -185,7 +180,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (next > maxTime) next = minTime;
|
if (next > maxTime) next = minTime;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [minTime, maxTime]);
|
}, [minTime, maxTime, setCurrentYear]);
|
||||||
|
|
||||||
// 日期时间选择处理
|
// 日期时间选择处理
|
||||||
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
const handleDateTimeChange = useCallback((newDate: Date | null) => {
|
||||||
@@ -212,7 +207,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, newInterval);
|
}, newInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying],
|
[isPlaying, maxTime, minTime, setCurrentYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 组件加载时设置初始时间为当前时间的最近15分钟
|
// 组件加载时设置初始时间为当前时间的最近15分钟
|
||||||
@@ -227,10 +222,21 @@ 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(
|
||||||
@@ -361,27 +367,12 @@ 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]);
|
}, [applyPipeHealthStyle, pipeLayer, predictionResults.length]);
|
||||||
|
|
||||||
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -422,7 +413,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
undoableTimeout: 3,
|
undoableTimeout: 3,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -484,6 +475,7 @@ 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}
|
||||||
@@ -492,6 +484,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 日期时间选择器 */}
|
{/* 日期时间选择器 */}
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -514,6 +507,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
ampm={false}
|
ampm={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip title="前进一天">
|
<Tooltip title="前进一天">
|
||||||
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDayStepForward}
|
onClick={handleDayStepForward}
|
||||||
@@ -526,6 +520,7 @@ 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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: number;
|
id: string;
|
||||||
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.name) {
|
if (!user || !user.id) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "用户信息无效",
|
message: "用户信息无效",
|
||||||
@@ -93,7 +93,7 @@ const OptimizationParameters: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 发送优化请求
|
// 发送优化请求
|
||||||
const response = await axios.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`,
|
`${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`,
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
@@ -104,6 +104,7 @@ 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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
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 "@app/OlMap/MapComponent";
|
import { useMap } from "@components/olmap/core/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]);
|
}, [highlightFeatures, highlightLayer]);
|
||||||
|
|
||||||
// 查询方案
|
// 查询方案
|
||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
@@ -148,7 +148,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
const response = await api.get(
|
||||||
`${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`,
|
`${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,8 +163,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSchemes(
|
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
||||||
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,
|
||||||
@@ -172,8 +171,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?.({
|
||||||
|
|||||||
@@ -1,414 +0,0 @@
|
|||||||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
||||||
import VectorLayer from "ol/layer/Vector";
|
|
||||||
import VectorSource from "ol/source/Vector";
|
|
||||||
import Style from "ol/style/Style";
|
|
||||||
import Fill from "ol/style/Fill";
|
|
||||||
import { Stroke } from "ol/style";
|
|
||||||
import GeoJson from "ol/format/GeoJSON";
|
|
||||||
import config from "@config/config";
|
|
||||||
import { useMap } from "@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;
|
|
||||||
+54
-14
@@ -37,14 +37,15 @@ 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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TimeSeriesPoint {
|
export interface TimeSeriesPoint {
|
||||||
@@ -67,6 +68,10 @@ 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";
|
||||||
@@ -96,10 +101,10 @@ const fetchFromBackend = async (
|
|||||||
try {
|
try {
|
||||||
// 优先查询清洗数据和模拟数据
|
// 优先查询清洗数据和模拟数据
|
||||||
const [cleaningRes, simulationRes] = await Promise.all([
|
const [cleaningRes, simulationRes] = await Promise.all([
|
||||||
fetch(cleaningDataUrl)
|
apiFetch(cleaningDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(simulationDataUrl)
|
apiFetch(simulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -118,7 +123,7 @@ const fetchFromBackend = async (
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
||||||
const rawRes = await fetch(rawDataUrl)
|
const rawRes = await apiFetch(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);
|
||||||
@@ -313,6 +318,8 @@ 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>();
|
||||||
@@ -338,13 +345,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([
|
||||||
fetch(cleaningDataUrl)
|
apiFetch(cleaningDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(rawDataUrl)
|
apiFetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(simulationDataUrl)
|
apiFetch(simulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -395,8 +402,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [showCleaning]);
|
}, [showCleaning]);
|
||||||
|
|
||||||
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
const [from, setFrom] = useState<Dayjs>(() => {
|
||||||
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
if (start_time) {
|
||||||
|
const parsedStart = dayjs(start_time);
|
||||||
|
if (parsedStart.isValid()) {
|
||||||
|
return parsedStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs().subtract(1, "day");
|
||||||
|
});
|
||||||
|
const [to, setTo] = useState<Dayjs>(() => {
|
||||||
|
if (end_time) {
|
||||||
|
const parsedEnd = dayjs(end_time);
|
||||||
|
if (parsedEnd.isValid()) {
|
||||||
|
return parsedEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs();
|
||||||
|
});
|
||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [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");
|
||||||
@@ -411,10 +434,27 @@ 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),
|
||||||
@@ -458,7 +498,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.name) {
|
if (!user || !user.id) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "用户信息无效,请重新登录",
|
message: "用户信息无效,请重新登录",
|
||||||
@@ -474,7 +514,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const endTime = dayjs(rangeTo).toISOString();
|
const endTime = dayjs(rangeTo).toISOString();
|
||||||
|
|
||||||
// 调用后端清洗接口
|
// 调用后端清洗接口
|
||||||
const response = await axios.post(
|
const response = await api.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(
|
||||||
@@ -527,7 +567,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
}
|
}
|
||||||
}, [deviceIds.join(",")]);
|
}, [deviceIdsKey, handleFetch, hasDevices]);
|
||||||
|
|
||||||
// 当设备数量变化时,调整数据源选择
|
// 当设备数量变化时,调整数据源选择
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
+16
-10
@@ -48,11 +48,12 @@ 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 axios from "axios";
|
import { api } from "@/lib/api";
|
||||||
import { useGetIdentity } from "@refinedev/core";
|
import { useGetIdentity } from "@refinedev/core";
|
||||||
import config, { NETWORK_NAME } from "@/config/config";
|
import config from "@/config/config";
|
||||||
|
|
||||||
import { useMap } from "@app/OlMap/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
import { GeoJSON } from "ol/format";
|
import { 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";
|
||||||
@@ -103,8 +104,8 @@ interface SCADADeviceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
||||||
@@ -180,12 +181,17 @@ 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 url = `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}:geo_scada&outputFormat=application/json`;
|
const activeWorkspace = workspace || config.MAP_WORKSPACE;
|
||||||
|
const url = `${config.MAP_URL}/${activeWorkspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${activeWorkspace}:geo_scada&outputFormat=application/json`;
|
||||||
const response = await fetch(url);
|
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();
|
||||||
@@ -211,7 +217,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchScadaDevices();
|
fetchScadaDevices();
|
||||||
}, []);
|
}, [workspace]);
|
||||||
|
|
||||||
const effectiveDevices = devices.length > 0 ? devices : internalDevices;
|
const effectiveDevices = devices.length > 0 ? devices : internalDevices;
|
||||||
|
|
||||||
@@ -595,7 +601,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.name) {
|
if (!user || !user.id) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "用户信息无效,请重新登录",
|
message: "用户信息无效,请重新登录",
|
||||||
@@ -616,7 +622,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
const endTime = dayjs(cleanEndTime).toISOString();
|
const endTime = dayjs(cleanEndTime).toISOString();
|
||||||
|
|
||||||
// 调用后端清洗接口
|
// 调用后端清洗接口
|
||||||
const response = await axios.post(
|
const response = await api.post(
|
||||||
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -735,7 +741,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
source.addFeature(feature);
|
source.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [selectedDeviceIds, highlightFeatures]);
|
}, [selectedDeviceIds, highlightFeatures, highlightLayer]);
|
||||||
// 清理定时器
|
// 清理定时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
+7
-4
@@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -136,7 +137,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
}
|
}
|
||||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
||||||
});
|
});
|
||||||
}, [map]);
|
}, [map, activeId]);
|
||||||
|
|
||||||
const changeMapLayers = (id: string) => {
|
const changeMapLayers = (id: string) => {
|
||||||
if (map) {
|
if (map) {
|
||||||
@@ -179,7 +180,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-17 bottom-8 z-1300">
|
<div className="absolute right-17 bottom-11 z-20">
|
||||||
<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}
|
||||||
@@ -187,7 +188,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()}>
|
||||||
<img
|
<Image
|
||||||
width={240}
|
width={240}
|
||||||
height={100}
|
height={100}
|
||||||
src={
|
src={
|
||||||
@@ -200,6 +201,7 @@ 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">
|
||||||
@@ -227,11 +229,12 @@ 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)}
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
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",
|
||||||
{
|
{
|
||||||
+44
-31
@@ -31,12 +31,15 @@ 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 [drawLayer, setDrawLayer] = useState<VectorLayer<VectorSource> | null>(
|
const drawLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]);
|
const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]);
|
||||||
const [historyStack, setHistoryStack] = useState<Feature<Geometry>[][]>([]);
|
const [history, setHistory] = useState<{
|
||||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
stack: Feature<Geometry>[][];
|
||||||
|
index: number;
|
||||||
|
}>({
|
||||||
|
stack: [[]],
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const drawInteractionRef = useRef<Draw | null>(null);
|
const drawInteractionRef = useRef<Draw | null>(null);
|
||||||
|
|
||||||
@@ -74,13 +77,14 @@ const DrawPanel: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.addLayer(drawVectorLayer);
|
map.addLayer(drawVectorLayer);
|
||||||
setDrawLayer(drawVectorLayer);
|
drawLayerRef.current = 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]);
|
||||||
@@ -88,14 +92,16 @@ const DrawPanel: React.FC = () => {
|
|||||||
// 保存到历史记录
|
// 保存到历史记录
|
||||||
const saveToHistory = useCallback(
|
const saveToHistory = useCallback(
|
||||||
(features: Feature<Geometry>[]) => {
|
(features: Feature<Geometry>[]) => {
|
||||||
setHistoryStack((prevStack) => {
|
setHistory((prev) => {
|
||||||
const newHistory = prevStack.slice(0, historyIndex + 1);
|
const newStack = prev.stack.slice(0, prev.index + 1);
|
||||||
newHistory.push([...features]);
|
newStack.push([...features]);
|
||||||
setHistoryIndex(newHistory.length - 1);
|
return {
|
||||||
return newHistory;
|
stack: newStack,
|
||||||
|
index: newStack.length - 1,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[historyIndex]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 添加绘图交互
|
// 添加绘图交互
|
||||||
@@ -103,6 +109,7 @@ 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;
|
||||||
|
|
||||||
@@ -153,7 +160,13 @@ const DrawPanel: React.FC = () => {
|
|||||||
// 绘图完成事件
|
// 绘图完成事件
|
||||||
draw.on("drawend", (event: DrawEvent) => {
|
draw.on("drawend", (event: DrawEvent) => {
|
||||||
const feature = event.feature;
|
const feature = event.feature;
|
||||||
const currentFeatures = [...drawnFeatures, feature];
|
const currentFeatures = [...source.getFeatures()];
|
||||||
|
|
||||||
|
// Fallback in case feature has not been synced to source yet.
|
||||||
|
if (!currentFeatures.includes(feature)) {
|
||||||
|
currentFeatures.push(feature);
|
||||||
|
}
|
||||||
|
|
||||||
setDrawnFeatures(currentFeatures);
|
setDrawnFeatures(currentFeatures);
|
||||||
saveToHistory(currentFeatures);
|
saveToHistory(currentFeatures);
|
||||||
});
|
});
|
||||||
@@ -244,28 +257,35 @@ const DrawPanel: React.FC = () => {
|
|||||||
|
|
||||||
// 撤销功能
|
// 撤销功能
|
||||||
const handleUndo = () => {
|
const handleUndo = () => {
|
||||||
if (historyIndex > 0) {
|
if (history.index > 0) {
|
||||||
const newIndex = historyIndex - 1;
|
const newIndex = history.index - 1;
|
||||||
const previousFeatures = historyStack[newIndex];
|
const previousFeatures = history.stack[newIndex];
|
||||||
updateDrawLayer(previousFeatures);
|
updateDrawLayer(previousFeatures);
|
||||||
setDrawnFeatures(previousFeatures);
|
setDrawnFeatures(previousFeatures);
|
||||||
setHistoryIndex(newIndex);
|
setHistory((prev) => ({
|
||||||
|
...prev,
|
||||||
|
index: newIndex,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重做功能
|
// 重做功能
|
||||||
const handleRedo = () => {
|
const handleRedo = () => {
|
||||||
if (historyIndex < historyStack.length - 1) {
|
if (history.index < history.stack.length - 1) {
|
||||||
const newIndex = historyIndex + 1;
|
const newIndex = history.index + 1;
|
||||||
const nextFeatures = historyStack[newIndex];
|
const nextFeatures = history.stack[newIndex];
|
||||||
updateDrawLayer(nextFeatures);
|
updateDrawLayer(nextFeatures);
|
||||||
setDrawnFeatures(nextFeatures);
|
setDrawnFeatures(nextFeatures);
|
||||||
setHistoryIndex(newIndex);
|
setHistory((prev) => ({
|
||||||
|
...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();
|
||||||
@@ -282,6 +302,7 @@ 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();
|
||||||
@@ -291,17 +312,9 @@ const DrawPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化历史记录
|
|
||||||
useEffect(() => {
|
|
||||||
// 初始化空的历史记录
|
|
||||||
if (historyStack.length === 0) {
|
|
||||||
saveToHistory([]);
|
|
||||||
}
|
|
||||||
}, [historyStack.length, saveToHistory]);
|
|
||||||
|
|
||||||
// 判断按钮是否应该禁用
|
// 判断按钮是否应该禁用
|
||||||
const isUndoDisabled = historyIndex <= 0;
|
const isUndoDisabled = history.index <= 0;
|
||||||
const isRedoDisabled = historyIndex >= historyStack.length - 1;
|
const isRedoDisabled = history.index >= history.stack.length - 1;
|
||||||
const isDeleteDisabled = drawnFeatures.length === 0;
|
const isDeleteDisabled = drawnFeatures.length === 0;
|
||||||
const isSaveDisabled = drawnFeatures.length === 0;
|
const isSaveDisabled = drawnFeatures.length === 0;
|
||||||
|
|
||||||
+55
-12
@@ -34,6 +34,7 @@ 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);
|
||||||
@@ -58,6 +59,10 @@ 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";
|
||||||
@@ -103,10 +108,10 @@ const fetchFromBackend = async (
|
|||||||
if (type === "none") {
|
if (type === "none") {
|
||||||
// 查询清洗值和监测值
|
// 查询清洗值和监测值
|
||||||
const [cleanedRes, rawRes] = await Promise.all([
|
const [cleanedRes, rawRes] = await Promise.all([
|
||||||
fetch(cleanedDataUrl)
|
apiFetch(cleanedDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(rawDataUrl)
|
apiFetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -126,13 +131,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([
|
||||||
fetch(cleanedDataUrl)
|
apiFetch(cleanedDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(rawDataUrl)
|
apiFetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(schemeSimulationDataUrl)
|
apiFetch(schemeSimulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -178,13 +183,13 @@ const fetchFromBackend = async (
|
|||||||
} else {
|
} else {
|
||||||
// realtime: 查询模拟值、清洗值和监测值
|
// realtime: 查询模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||||
fetch(cleanedDataUrl)
|
apiFetch(cleanedDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(rawDataUrl)
|
apiFetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetch(simulationDataUrl)
|
apiFetch(simulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
@@ -391,10 +396,12 @@ 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(
|
||||||
@@ -402,8 +409,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
[featureInfos]
|
[featureInfos]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
const [from, setFrom] = useState<Dayjs>(() => {
|
||||||
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
if (start_time) {
|
||||||
|
const parsedStart = dayjs(start_time);
|
||||||
|
if (parsedStart.isValid()) {
|
||||||
|
return parsedStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs().subtract(1, "day");
|
||||||
|
});
|
||||||
|
const [to, setTo] = useState<Dayjs>(() => {
|
||||||
|
if (end_time) {
|
||||||
|
const parsedEnd = dayjs(end_time);
|
||||||
|
if (parsedEnd.isValid()) {
|
||||||
|
return parsedEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs();
|
||||||
|
});
|
||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [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");
|
||||||
@@ -417,10 +440,30 @@ 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),
|
||||||
@@ -467,7 +510,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
}
|
}
|
||||||
}, [JSON.stringify(featureInfos)]);
|
}, [featureInfosKey, handleFetch, hasDevices]);
|
||||||
|
|
||||||
// 当设备数量变化时,调整数据源选择
|
// 当设备数量变化时,调整数据源选择
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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;
|
||||||
+146
-170
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
|
|
||||||
// 导入Material-UI图标和组件
|
// 导入Material-UI图标和组件
|
||||||
import ColorLensIcon from "@mui/icons-material/ColorLens";
|
import ColorLensIcon from "@mui/icons-material/ColorLens";
|
||||||
@@ -180,26 +180,21 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
if (!data) {
|
const currentJunctionCalData = data?.currentJunctionCalData;
|
||||||
return <div>Loading...</div>; // 或其他占位符
|
const currentPipeCalData = data?.currentPipeCalData;
|
||||||
}
|
const junctionText = data?.junctionText ?? "";
|
||||||
const {
|
const pipeText = data?.pipeText ?? "";
|
||||||
currentJunctionCalData,
|
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
||||||
currentPipeCalData,
|
const setShowPipeTextLayer = data?.setShowPipeTextLayer;
|
||||||
junctionText,
|
const setShowJunctionId = data?.setShowJunctionId;
|
||||||
pipeText,
|
const setShowPipeId = data?.setShowPipeId;
|
||||||
setShowJunctionTextLayer,
|
const setContourLayerAvailable = data?.setContourLayerAvailable;
|
||||||
setShowPipeTextLayer,
|
const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable;
|
||||||
setShowJunctionId,
|
const setJunctionText = data?.setJunctionText;
|
||||||
setShowPipeId,
|
const setPipeText = data?.setPipeText;
|
||||||
setContourLayerAvailable,
|
const setContours = data?.setContours;
|
||||||
setWaterflowLayerAvailable,
|
const diameterRange = data?.diameterRange;
|
||||||
setJunctionText,
|
const elevationRange = data?.elevationRange;
|
||||||
setPipeText,
|
|
||||||
setContours,
|
|
||||||
diameterRange,
|
|
||||||
elevationRange,
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
const unitHeadlossRange = [0, 5];
|
const unitHeadlossRange = [0, 5];
|
||||||
|
|
||||||
@@ -213,9 +208,6 @@ 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",
|
||||||
@@ -237,6 +229,74 @@ 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[] => {
|
||||||
@@ -261,7 +321,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
}
|
}
|
||||||
return colors;
|
return colors;
|
||||||
},
|
},
|
||||||
[styleConfig.gradientPaletteIndex, parseColor]
|
[styleConfig.gradientPaletteIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 根据分段数生成彩虹色
|
// 根据分段数生成彩虹色
|
||||||
@@ -278,8 +338,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
[styleConfig.rainbowPaletteIndex]
|
[styleConfig.rainbowPaletteIndex]
|
||||||
);
|
);
|
||||||
// 保存当前图层的样式状态
|
// 保存当前图层的样式状态
|
||||||
const saveLayerStyle = useCallback(
|
const saveLayerStyle = (
|
||||||
(
|
|
||||||
layerId?: string,
|
layerId?: string,
|
||||||
newLegendConfig?: LegendStyleConfig,
|
newLegendConfig?: LegendStyleConfig,
|
||||||
overrideStyleConfig?: StyleConfig
|
overrideStyleConfig?: StyleConfig
|
||||||
@@ -290,8 +349,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
console.warn("无法保存样式:缺少必要的图层或样式配置");
|
console.warn("无法保存样式:缺少必要的图层或样式配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!layerId) return; // 如果没有传入 layerId,则不保存
|
if (!layerId) return;
|
||||||
// 如果没有传入图例配置,则创建一个默认的空配置
|
|
||||||
const layerName =
|
const layerName =
|
||||||
newLegendConfig?.layerName ||
|
newLegendConfig?.layerName ||
|
||||||
selectedRenderLayer?.get("name") ||
|
selectedRenderLayer?.get("name") ||
|
||||||
@@ -299,7 +358,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
const property = availableProperties.find(
|
const property = availableProperties.find(
|
||||||
(p) => p.value === currentStyleConfig.property
|
(p) => p.value === currentStyleConfig.property
|
||||||
);
|
);
|
||||||
let legendConfig: LegendStyleConfig = newLegendConfig || {
|
const legendConfig: LegendStyleConfig = newLegendConfig || {
|
||||||
layerId,
|
layerId,
|
||||||
layerName,
|
layerName,
|
||||||
property: property?.name || currentStyleConfig.property,
|
property: property?.name || currentStyleConfig.property,
|
||||||
@@ -316,25 +375,19 @@ 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;
|
||||||
@@ -787,7 +840,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 重置样式
|
// 重置样式
|
||||||
const resetStyle = useCallback(() => {
|
const resetStyle = () => {
|
||||||
if (!selectedRenderLayer) return;
|
if (!selectedRenderLayer) return;
|
||||||
// 重置 WebGL 图层样式
|
// 重置 WebGL 图层样式
|
||||||
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
||||||
@@ -815,7 +868,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;
|
||||||
@@ -857,7 +910,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
|
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
|
||||||
const [tileLoadListeners, setTileLoadListeners] = useState<
|
const tileLoadListenersRef = useRef<
|
||||||
Map<VectorTileSource, (event: any) => void>
|
Map<VectorTileSource, (event: any) => void>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
|
|
||||||
@@ -879,8 +932,6 @@ 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) {
|
||||||
@@ -906,8 +957,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
vectorTileSource.on("tileloadend", listener);
|
vectorTileSource.on("tileloadend", listener);
|
||||||
newListeners.set(vectorTileSource, listener);
|
tileLoadListenersRef.current.set(vectorTileSource, listener);
|
||||||
setTileLoadListeners(newListeners);
|
|
||||||
};
|
};
|
||||||
// 新增函数:取消对应 layerId 已添加的 on 事件
|
// 新增函数:取消对应 layerId 已添加的 on 事件
|
||||||
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
|
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
|
||||||
@@ -918,14 +968,10 @@ 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 = tileLoadListeners.get(vectorTileSource);
|
const listener = tileLoadListenersRef.current.get(vectorTileSource);
|
||||||
if (listener) {
|
if (listener) {
|
||||||
vectorTileSource.un("tileloadend", listener);
|
vectorTileSource.un("tileloadend", listener);
|
||||||
setTileLoadListeners((prev) => {
|
tileLoadListenersRef.current.delete(vectorTileSource);
|
||||||
const newMap = new Map(prev);
|
|
||||||
newMap.delete(vectorTileSource);
|
|
||||||
return newMap;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1019,6 +1065,8 @@ 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,
|
||||||
@@ -1044,117 +1092,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
|
|
||||||
updateVisibleLayers();
|
updateVisibleLayers();
|
||||||
}, [map]);
|
}, [map]);
|
||||||
// 获取选中图层的属性,并检查是否有已缓存的样式状态
|
if (!data) {
|
||||||
useEffect(() => {
|
return <div>Loading...</div>;
|
||||||
// 如果没有矢量图层或没有选中图层,清空属性列表
|
|
||||||
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") {
|
||||||
@@ -1624,9 +1564,21 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
const cachedStyleState = layerStyleStates.find(
|
const cachedStyleState = layerStyleStates.find(
|
||||||
(state) => state.layerId === layerId
|
(state) => state.layerId === layerId
|
||||||
);
|
);
|
||||||
// 只有在没有缓存时才清空属性
|
if (cachedStyleState) {
|
||||||
if (!cachedStyleState) {
|
setStyleConfig(cachedStyleState.styleConfig);
|
||||||
setStyleConfig((prev) => ({ ...prev, property: "" }));
|
} else {
|
||||||
|
setStyleConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
property: "",
|
||||||
|
customBreaks:
|
||||||
|
prev.classificationMethod === "custom_breaks"
|
||||||
|
? getDefaultCustomBreaks(prev.segments, "", newLayer)
|
||||||
|
: prev.customBreaks,
|
||||||
|
customColors: getDefaultCustomColors(
|
||||||
|
prev.segments,
|
||||||
|
prev.customColors
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1647,7 +1599,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
<Select
|
<Select
|
||||||
value={styleConfig.property}
|
value={styleConfig.property}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setStyleConfig((prev) => ({ ...prev, property: e.target.value }));
|
const nextProperty = e.target.value;
|
||||||
|
setStyleConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
property: nextProperty,
|
||||||
|
customBreaks:
|
||||||
|
prev.classificationMethod === "custom_breaks"
|
||||||
|
? getDefaultCustomBreaks(prev.segments, nextProperty)
|
||||||
|
: prev.customBreaks,
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
disabled={!selectedRenderLayer}
|
disabled={!selectedRenderLayer}
|
||||||
>
|
>
|
||||||
@@ -1664,9 +1624,14 @@ 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: e.target.value,
|
classificationMethod: nextMethod,
|
||||||
|
customBreaks:
|
||||||
|
nextMethod === "custom_breaks"
|
||||||
|
? getDefaultCustomBreaks(prev.segments, prev.property)
|
||||||
|
: prev.customBreaks,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1695,7 +1660,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
segments: newSegments,
|
segments: newSegments,
|
||||||
customColors: newCustomColors,
|
customBreaks:
|
||||||
|
prev.classificationMethod === "custom_breaks"
|
||||||
|
? getDefaultCustomBreaks(newSegments, prev.property)
|
||||||
|
: prev.customBreaks,
|
||||||
|
customColors: getDefaultCustomColors(
|
||||||
|
newSegments,
|
||||||
|
newCustomColors
|
||||||
|
),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1782,6 +1754,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
colorType: newColorType,
|
colorType: newColorType,
|
||||||
|
adjustWidthByProperty:
|
||||||
|
newColorType === "single"
|
||||||
|
? true
|
||||||
|
: prev.adjustWidthByProperty,
|
||||||
customColors: newCustomColors,
|
customColors: newCustomColors,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
+27
-2
@@ -10,6 +10,9 @@ interface LegendStyleConfig {
|
|||||||
type: string; // 图例类型
|
type: string; // 图例类型
|
||||||
dimensions: number[]; // 尺寸大小
|
dimensions: number[]; // 尺寸大小
|
||||||
breaks: number[]; // 分段值
|
breaks: number[]; // 分段值
|
||||||
|
labels?: string[]; // 可选标签(用于离散分类)
|
||||||
|
columns?: number;
|
||||||
|
itemsPerColumn?: number;
|
||||||
}
|
}
|
||||||
// 图例组件
|
// 图例组件
|
||||||
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
||||||
@@ -24,6 +27,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
type, // 图例类型
|
type, // 图例类型
|
||||||
dimensions,
|
dimensions,
|
||||||
breaks,
|
breaks,
|
||||||
|
labels,
|
||||||
|
columns = 1,
|
||||||
|
itemsPerColumn,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -33,6 +39,23 @@ 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
|
||||||
@@ -70,7 +93,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 mb-1">
|
<Box key={index} className="flex items-center gap-2">
|
||||||
<Box
|
<Box
|
||||||
sx={
|
sx={
|
||||||
type === "point"
|
type === "point"
|
||||||
@@ -89,7 +112,8 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" className="text-xs">
|
<Typography variant="caption" className="text-xs">
|
||||||
{prevValue?.toFixed(1)} - {currentValue?.toFixed(1)}
|
{labels?.[index] ??
|
||||||
|
`${prevValue?.toFixed(1)} - ${currentValue?.toFixed(1)}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -98,6 +122,7 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
+63
-57
@@ -28,6 +28,7 @@ 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 {
|
||||||
@@ -38,37 +39,33 @@ interface TimelineProps {
|
|||||||
schemeType?: string;
|
schemeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
|
||||||
|
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
|
||||||
|
|
||||||
const Timeline: React.FC<TimelineProps> = ({
|
const Timeline: React.FC<TimelineProps> = ({
|
||||||
schemeDate,
|
schemeDate,
|
||||||
timeRange,
|
timeRange,
|
||||||
disableDateSelection = false,
|
disableDateSelection = false,
|
||||||
schemeName = "",
|
schemeName = "",
|
||||||
schemeType = "burst_Analysis",
|
schemeType = "burst_analysis",
|
||||||
}) => {
|
}) => {
|
||||||
const data = useData();
|
const data = useData();
|
||||||
if (!data) {
|
const fallbackSelectedDateRef = useRef(new Date());
|
||||||
return <div>Loading...</div>; // 或其他占位符
|
const hasTimelineState =
|
||||||
}
|
data &&
|
||||||
const {
|
data.setCurrentTime !== undefined &&
|
||||||
currentTime,
|
data.currentTime !== undefined &&
|
||||||
setCurrentTime,
|
data.selectedDate !== undefined &&
|
||||||
selectedDate,
|
data.setSelectedDate !== undefined;
|
||||||
setSelectedDate,
|
const currentTime = data?.currentTime ?? -1;
|
||||||
setCurrentJunctionCalData,
|
const setCurrentTime = data?.setCurrentTime ?? NOOP_SET_CURRENT_TIME;
|
||||||
setCurrentPipeCalData,
|
const selectedDate = data?.selectedDate ?? fallbackSelectedDateRef.current;
|
||||||
junctionText,
|
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
|
||||||
pipeText,
|
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
|
||||||
} = data;
|
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
|
||||||
if (
|
const junctionText = data?.junctionText ?? "";
|
||||||
setCurrentTime === undefined ||
|
const pipeText = data?.pipeText ?? "";
|
||||||
currentTime === undefined ||
|
|
||||||
selectedDate === undefined ||
|
|
||||||
setSelectedDate === undefined
|
|
||||||
) {
|
|
||||||
return <div>Loading...</div>; // 或其他占位符
|
|
||||||
}
|
|
||||||
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); // 分钟
|
||||||
@@ -85,7 +82,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (schemeDate) {
|
if (schemeDate) {
|
||||||
setSelectedDate(schemeDate);
|
setSelectedDate(schemeDate);
|
||||||
}
|
}
|
||||||
}, [schemeDate]);
|
}, [schemeDate, setSelectedDate]);
|
||||||
// 新增:用于 Draggable 的 nodeRef
|
// 新增:用于 Draggable 的 nodeRef
|
||||||
const draggableRef = useRef<HTMLDivElement>(null);
|
const draggableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -97,7 +94,20 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 添加防抖引用
|
// 添加防抖引用
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const fetchFrameData = async (
|
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => {
|
||||||
|
if (setCurrentJunctionCalData) {
|
||||||
|
setCurrentJunctionCalData(nodeResults);
|
||||||
|
} else {
|
||||||
|
console.log("setCurrentJunctionCalData is undefined");
|
||||||
|
}
|
||||||
|
if (setCurrentPipeCalData) {
|
||||||
|
setCurrentPipeCalData(linkResults);
|
||||||
|
} else {
|
||||||
|
console.log("setCurrentPipeCalData is undefined");
|
||||||
|
}
|
||||||
|
}, [setCurrentJunctionCalData, setCurrentPipeCalData]);
|
||||||
|
|
||||||
|
const fetchFrameData = useCallback(async (
|
||||||
queryTime: Date,
|
queryTime: Date,
|
||||||
junctionProperties: string,
|
junctionProperties: string,
|
||||||
pipeProperties: string,
|
pipeProperties: string,
|
||||||
@@ -117,11 +127,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
disableDateSelection && schemeName
|
disableDateSelection && schemeName
|
||||||
? (nodePromise = fetch(
|
? (nodePromise = apiFetch(
|
||||||
// `${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 = fetch(
|
: (nodePromise = apiFetch(
|
||||||
// `${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}`,
|
||||||
));
|
));
|
||||||
@@ -138,11 +148,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
disableDateSelection && schemeName
|
disableDateSelection && schemeName
|
||||||
? (linkPromise = fetch(
|
? (linkPromise = apiFetch(
|
||||||
// `${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 = fetch(
|
: (linkPromise = apiFetch(
|
||||||
// `${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}`,
|
||||||
));
|
));
|
||||||
@@ -177,21 +187,7 @@ 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) => ({
|
||||||
@@ -248,7 +244,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
setCurrentTime(value);
|
setCurrentTime(value);
|
||||||
}, 500); // 500ms 防抖延迟
|
}, 500); // 500ms 防抖延迟
|
||||||
},
|
},
|
||||||
[timeRange, minTime, maxTime],
|
[timeRange, minTime, maxTime, setCurrentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 播放控制
|
// 播放控制
|
||||||
@@ -275,7 +271,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
});
|
});
|
||||||
}, playInterval);
|
}, playInterval);
|
||||||
}
|
}
|
||||||
}, [isPlaying, playInterval]);
|
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]);
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -295,7 +291,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(() => {
|
||||||
@@ -304,14 +300,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;
|
||||||
@@ -322,7 +318,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime]);
|
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
||||||
|
|
||||||
const handleStepForward = useCallback(() => {
|
const handleStepForward = useCallback(() => {
|
||||||
setCurrentTime((prev) => {
|
setCurrentTime((prev) => {
|
||||||
@@ -334,14 +330,14 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [timeRange, minTime, maxTime]);
|
}, [timeRange, minTime, maxTime, setCurrentTime]);
|
||||||
|
|
||||||
// 日期选择处理
|
// 日期选择处理
|
||||||
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(
|
||||||
@@ -365,7 +361,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
}, newInterval);
|
}, newInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPlaying],
|
[isPlaying, timeRange, maxTime, minTime, setCurrentTime],
|
||||||
);
|
);
|
||||||
// 计算时间段改变处理
|
// 计算时间段改变处理
|
||||||
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
||||||
@@ -396,6 +392,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
fetchFrameData,
|
||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -421,14 +418,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]);
|
}, [timeRange, minTime, setCurrentTime]);
|
||||||
// 获取地图实例
|
// 获取地图实例
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
|
||||||
@@ -513,7 +510,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
duration: calculatedInterval,
|
duration: calculatedInterval,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
|
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -548,6 +545,10 @@ 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
|
||||||
@@ -604,6 +605,7 @@ 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}
|
||||||
@@ -612,6 +614,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
>
|
>
|
||||||
<FiSkipBack />
|
<FiSkipBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 日期选择器 */}
|
{/* 日期选择器 */}
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -632,17 +635,20 @@ 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() === new Date().toDateString()
|
selectedDate.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">
|
||||||
+160
-19
@@ -8,35 +8,42 @@ 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 } from "@/utils/mapQueryService";
|
import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } 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> = ({
|
||||||
hiddenButtons,
|
hiddenButtons,
|
||||||
queryType,
|
queryType,
|
||||||
|
schemeType,
|
||||||
HistoryPanel,
|
HistoryPanel,
|
||||||
}) => {
|
}) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
if (!data) return null;
|
|
||||||
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);
|
||||||
@@ -45,6 +52,106 @@ 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[]>([
|
||||||
@@ -323,6 +430,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
case "history":
|
case "history":
|
||||||
setShowHistoryPanel(false);
|
setShowHistoryPanel(false);
|
||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
|
setChatPanelFeatureInfos(null);
|
||||||
|
setChatPanelTimeRange(null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -349,6 +458,8 @@ 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<
|
||||||
@@ -386,12 +497,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 fetch(
|
response = await apiFetch(
|
||||||
// `${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_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
response = await fetch(
|
response = await apiFetch(
|
||||||
// `${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}`,
|
||||||
);
|
);
|
||||||
@@ -400,7 +511,12 @@ 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();
|
||||||
setComputedProperties(data.results[0] || {});
|
if (!data.result || data.result.length === 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({});
|
||||||
@@ -408,7 +524,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
};
|
};
|
||||||
// 仅当 currentTime 有效时查询
|
// 仅当 currentTime 有效时查询
|
||||||
if (currentTime !== -1 && queryType) queryComputedProperties();
|
if (currentTime !== -1 && queryType) queryComputedProperties();
|
||||||
}, [highlightFeatures, currentTime, selectedDate]);
|
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
||||||
|
|
||||||
// 从要素属性中提取属性面板需要的数据
|
// 从要素属性中提取属性面板需要的数据
|
||||||
const getFeatureProperties = useCallback(() => {
|
const getFeatureProperties = useCallback(() => {
|
||||||
@@ -418,7 +534,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const properties = highlightFeature.getProperties();
|
const properties = highlightFeature.getProperties();
|
||||||
// 计算属性字段,增加 key 字段
|
// 计算属性字段,增加 key 字段
|
||||||
const pipeComputedFields = [
|
const pipeComputedFields = [
|
||||||
{ key: "flow", label: "流量", unit: "m³/h" },
|
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
|
||||||
{ 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" },
|
||||||
@@ -429,7 +545,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: "m³/h" },
|
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
|
||||||
{ 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" },
|
||||||
@@ -457,6 +573,11 @@ 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" &&
|
||||||
@@ -495,10 +616,11 @@ 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) => {
|
||||||
const d = properties?.[`demand${idx}`]?.toFixed?.(3);
|
let d = properties?.[`demand${idx}`];
|
||||||
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 ?? "-"];
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -510,10 +632,14 @@ 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:
|
||||||
computedProperties[key].toFixed?.(3) || computedProperties[key],
|
value?.toFixed?.(3) || value,
|
||||||
unit,
|
unit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -701,6 +827,10 @@ 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">
|
||||||
@@ -746,9 +876,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showHistoryPanel &&
|
{showHistoryPanel &&
|
||||||
(HistoryPanel ? (
|
(chatPanelType === "none" && chatPanelFeatureInfos ? (
|
||||||
|
<SCADADataPanel
|
||||||
|
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
|
||||||
|
visible={showHistoryPanel}
|
||||||
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
|
/>
|
||||||
|
) : HistoryPanel ? (
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
featureInfos={(() => {
|
featureInfos={chatPanelFeatureInfos ?? (() => {
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
@@ -784,13 +921,15 @@ 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={queryType as "realtime" | "scheme" | "none"}
|
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
||||||
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HistoryDataPanel
|
<HistoryDataPanel
|
||||||
featureInfos={(() => {
|
featureInfos={chatPanelFeatureInfos ?? (() => {
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
@@ -826,9 +965,11 @@ 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={queryType as "realtime" | "scheme" | "none"}
|
type={chatPanelFeatureInfos ? chatPanelType : (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-8 z-1300">
|
<div className="absolute right-4 bottom-11 z-20">
|
||||||
<div className="w-8 h-26 flex flex-col gap-2 items-center">
|
<div className="w-8 h-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,5 +1,6 @@
|
|||||||
"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,
|
||||||
@@ -32,6 +33,7 @@ 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;
|
||||||
@@ -75,20 +77,34 @@ 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
|
|
||||||
// 添加防抖函数
|
// 添加防抖函数
|
||||||
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
type DebouncedFunction<F extends (...args: any[]) => any> = ((
|
||||||
|
...args: Parameters<F>
|
||||||
|
) => void) & {
|
||||||
|
cancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function debounce<F extends (...args: any[]) => any>(
|
||||||
|
func: F,
|
||||||
|
waitFor: number
|
||||||
|
): DebouncedFunction<F> {
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
return (...args: Parameters<F>): void => {
|
const debounced = (...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 = () => {
|
||||||
@@ -99,8 +115,22 @@ 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>();
|
||||||
@@ -143,7 +173,12 @@ 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);
|
||||||
return record ? { ...j, [junctionText]: record.value } : j;
|
let val = record ? record.value : undefined;
|
||||||
|
// 在这合并时将实际需水量从 LPS 转换为大写表示
|
||||||
|
if (val !== undefined && junctionText === "actualdemand") {
|
||||||
|
val = toM3h(val, "lps");
|
||||||
|
}
|
||||||
|
return record ? { ...j, [junctionText]: val } : j;
|
||||||
});
|
});
|
||||||
}, [junctionData, currentJunctionCalData, junctionText]);
|
}, [junctionData, currentJunctionCalData, junctionText]);
|
||||||
|
|
||||||
@@ -153,9 +188,13 @@ 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(record.value) : record.value,
|
[pipeText]: isFlow ? Math.abs(val) : val,
|
||||||
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,
|
||||||
};
|
};
|
||||||
@@ -169,20 +208,6 @@ 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;
|
||||||
@@ -214,6 +239,7 @@ 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;
|
||||||
@@ -245,6 +271,28 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const debouncedUpdateDataRef = useRef<DebouncedFunction<() => void> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
debouncedUpdateDataRef.current = debounce(() => {
|
||||||
|
if (tileJunctionDataBuffer.current.length > 0) {
|
||||||
|
setJunctionData(tileJunctionDataBuffer.current);
|
||||||
|
tileJunctionDataBuffer.current = [];
|
||||||
|
}
|
||||||
|
if (tilePipeDataBuffer.current.length > 0) {
|
||||||
|
setPipeData(tilePipeDataBuffer.current);
|
||||||
|
tilePipeDataBuffer.current = [];
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
debouncedUpdateDataRef.current?.cancel();
|
||||||
|
debouncedUpdateDataRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
// 配置地图数据源、图层和样式
|
// 配置地图数据源、图层和样式
|
||||||
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
||||||
// 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标
|
// 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标
|
||||||
@@ -459,7 +507,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
const scadaLayer = new VectorLayer({
|
const scadaLayer = new VectorLayer({
|
||||||
source: scadaSource,
|
source: scadaSource,
|
||||||
style: scadaStyle,
|
style: scadaStyle,
|
||||||
// extent: extent, // 设置图层范围
|
extent: MAP_EXTENT, // 设置图层范围
|
||||||
maxZoom: 24,
|
maxZoom: 24,
|
||||||
minZoom: 11,
|
minZoom: 11,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -470,16 +518,40 @@ 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 提供坐标供标签显示
|
||||||
junctionSource.on("tileloadend", (event) => {
|
const handleJunctionTileLoadEnd = (event: any) => {
|
||||||
|
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) => {
|
renderFeatures.forEach((renderFeature: any) => {
|
||||||
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)) {
|
||||||
@@ -502,20 +574,21 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
uniqueData.forEach((item) =>
|
uniqueData.forEach((item) =>
|
||||||
tileJunctionDataBuffer.current.push(item),
|
tileJunctionDataBuffer.current.push(item),
|
||||||
);
|
);
|
||||||
debouncedUpdateData.current();
|
debouncedUpdateDataRef.current?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Junction tile load error:", error);
|
console.error("Junction tile load error:", error);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
pipeSource.on("tileloadend", (event) => {
|
const handlePipeTileLoadEnd = (event: any) => {
|
||||||
|
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) => {
|
renderFeatures.forEach((renderFeature: any) => {
|
||||||
try {
|
try {
|
||||||
const props = renderFeature.getProperties();
|
const props = renderFeature.getProperties();
|
||||||
const featureId = props.id;
|
const featureId = props.id;
|
||||||
@@ -582,13 +655,15 @@ 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));
|
||||||
debouncedUpdateData.current();
|
debouncedUpdateDataRef.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();
|
||||||
@@ -702,6 +777,7 @@ 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();
|
||||||
@@ -719,7 +795,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
// 监听缩放变化并持久化,同时更新 currentZoom
|
// 监听缩放变化并持久化,同时更新 currentZoom
|
||||||
const handleViewChange = () => {
|
const handleViewChange = () => {
|
||||||
setTimeout(() => {
|
addTimeout(() => {
|
||||||
const zoom = map.getView().getZoom() || 0;
|
const zoom = map.getView().getZoom() || 0;
|
||||||
setCurrentZoom(zoom);
|
setCurrentZoom(zoom);
|
||||||
persistView();
|
persistView();
|
||||||
@@ -728,7 +804,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
map.getView().on("change", handleViewChange);
|
map.getView().on("change", handleViewChange);
|
||||||
|
|
||||||
// 初始化当前缩放级别并强制触发瓦片加载
|
// 初始化当前缩放级别并强制触发瓦片加载
|
||||||
setTimeout(() => {
|
addTimeout(() => {
|
||||||
const initialZoom = map.getView().getZoom() || 11;
|
const initialZoom = map.getView().getZoom() || 11;
|
||||||
setCurrentZoom(initialZoom);
|
setCurrentZoom(initialZoom);
|
||||||
// 强制触发地图渲染,让瓦片加载事件触发
|
// 强制触发地图渲染,让瓦片加载事件触发
|
||||||
@@ -742,11 +818,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
latitude: 0,
|
latitude: 0,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
},
|
},
|
||||||
canvas: "deck-canvas",
|
canvas: canvasRef.current,
|
||||||
controller: false, // 由 OpenLayers 控制视图
|
controller: false, // 由 OpenLayers 控制视图
|
||||||
layers: [],
|
layers: [],
|
||||||
});
|
});
|
||||||
const deckLayer = new DeckLayer(deck, {
|
const deckLayer = new DeckLayer(deck, canvasRef.current, {
|
||||||
name: "deckLayer",
|
name: "deckLayer",
|
||||||
value: "deckLayer",
|
value: "deckLayer",
|
||||||
});
|
});
|
||||||
@@ -756,18 +832,37 @@ 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({
|
||||||
@@ -782,15 +877,7 @@ 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;
|
||||||
@@ -842,17 +929,7 @@ 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;
|
||||||
@@ -935,6 +1012,7 @@ 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 {
|
||||||
@@ -947,6 +1025,7 @@ 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;
|
||||||
@@ -1046,7 +1125,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
<MapTools />
|
<MapTools />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<canvas id="deck-canvas" />
|
<canvas ref={canvasRef} />
|
||||||
</MapContext.Provider>
|
</MapContext.Provider>
|
||||||
</DataContext.Provider>
|
</DataContext.Provider>
|
||||||
</>
|
</>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user