Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fc1812d53 | |||
| 709b029c4e | |||
| 57369772c7 | |||
| 7764e25398 | |||
| e60e1f6453 | |||
| 20ca410e0a | |||
| 06a3f32d2d | |||
| fa3e6b6e84 | |||
| 888132a60f | |||
| 9761ade8d8 | |||
| 0e82c080df | |||
| a4f0ffcd32 | |||
| 9dc8549f31 | |||
| 6b447eb398 | |||
| 54fbf15be8 | |||
| 4bf99e8069 | |||
| e4d45300b1 | |||
| 477350a2a1 | |||
| 424555aae2 | |||
| 98635e5247 | |||
| adf8ea5ca8 | |||
| 91a57123a4 | |||
| 4f54da64d0 | |||
| 2fbfba118f | |||
| 9106b8d4a9 | |||
| 3800d73e85 | |||
| e4424b87d1 | |||
| 39ee9a02e5 | |||
| 45274955c6 | |||
| 03ca56d2a7 | |||
| 570d2c7de1 | |||
| 8058b7b859 | |||
| a4486e3d89 | |||
| 536cd6a5d1 | |||
| 133f5d417f | |||
| cf43700459 | |||
| 5cfb7cc38f | |||
| d4050a841b | |||
| ba66abb4ee | |||
| e0e78cd95a | |||
| c5b0f43a0d | |||
| 8f3c288823 | |||
| 24d81e04e0 | |||
| 85b4f45d4a | |||
| 36d1a8d6ea | |||
| e5ca9e24aa | |||
| 2c1afdc97c | |||
| 30d85173ee | |||
| 3b5a493cda | |||
| 49fd4f5eb1 | |||
| 3db2af0271 | |||
| 07861bee03 | |||
| 60181dba54 | |||
| a1442fc062 | |||
| 260c493fc8 | |||
| 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_AGENT_URL="https://agent.waternetwork.cn"
|
||||||
|
NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
|
||||||
|
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
|
||||||
|
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
|
||||||
|
NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25"
|
||||||
|
NEXT_PUBLIC_NETWORK_NAME="tjwater"
|
||||||
|
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"
|
||||||
|
NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
name: Build Push and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
- "latest"
|
||||||
|
|
||||||
|
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#/}"
|
||||||
|
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
|
||||||
|
{
|
||||||
|
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
||||||
|
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
||||||
|
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
|
||||||
|
echo "IMAGE_NAME=${IMAGE_NAME}"
|
||||||
|
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||||
|
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \
|
||||||
|
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build and Push Image
|
||||||
|
run: |
|
||||||
|
push_with_retry() {
|
||||||
|
image_ref="$1"
|
||||||
|
attempt=1
|
||||||
|
max_attempts=3
|
||||||
|
|
||||||
|
while [ "$attempt" -le "$max_attempts" ]; do
|
||||||
|
if docker push "$image_ref"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
-f ./Dockerfile \
|
||||||
|
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||||
|
-t "${IMAGE_NAME}:latest" \
|
||||||
|
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \
|
||||||
|
.
|
||||||
|
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
push_with_retry "${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
|
- name: Notify Deploy Server
|
||||||
|
run: |
|
||||||
|
post_deploy_webhook() {
|
||||||
|
label="$1"
|
||||||
|
payload="$2"
|
||||||
|
|
||||||
|
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
|
||||||
|
-d "$payload")
|
||||||
|
|
||||||
|
echo "[$label] webhook HTTP status: ${http_code}"
|
||||||
|
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$label] response headers:"
|
||||||
|
cat /tmp/deploy_headers.txt
|
||||||
|
echo "[$label] response body:"
|
||||||
|
cat /tmp/deploy_response.txt
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
|
||||||
|
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
|
||||||
|
|
||||||
|
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
|
||||||
|
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
|
||||||
|
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Primary webhook request failed, retrying with lowercase repo path..."
|
||||||
|
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
|
||||||
|
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploy webhook failed after primary and fallback attempts."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
deploy-fallback-log:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: docker-image
|
||||||
|
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_AGENT_URL
|
||||||
|
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
|
||||||
|
ARG NEXT_PUBLIC_MAP_URL
|
||||||
|
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
||||||
|
ARG NEXT_PUBLIC_MAP_EXTENT
|
||||||
|
ARG NEXT_PUBLIC_NETWORK_NAME
|
||||||
|
ARG NEXT_PUBLIC_MAPBOX_TOKEN
|
||||||
|
ARG NEXT_PUBLIC_TIANDITU_TOKEN
|
||||||
|
|
||||||
COPY --from=deps /app/refine/node_modules ./node_modules
|
COPY --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_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL}
|
||||||
|
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
|
||||||
|
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
|
||||||
|
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
|
||||||
|
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
|
||||||
|
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
|
||||||
|
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
|
||||||
|
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
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,22 @@
|
|||||||
|
# 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.
|
||||||
|
- **Workflow mode changed for full CD verification:** per latest request, all `*-test` bypass logic was removed again so the workflow always runs registry login, image push, and deploy webhook. Full deployment validation now depends on using a normal `v*` tag and observing the real CD result instead of synthetic skip branches.
|
||||||
|
- **Next full-CD failure on act_runner:** image build completed, but pushing to the Gitea registry failed on blob upload commit with `failed to do request: Put ... EOF`. This is past the workflow logic stage and points to a transient or infrastructure-side registry upload failure.
|
||||||
|
- **Applied push hardening:** wrapped both `docker push "${IMAGE_NAME}:${IMAGE_TAG}"` and `docker push "${IMAGE_NAME}:latest"` in a 3-attempt retry helper with a short backoff to absorb transient registry EOF failures.
|
||||||
|
- **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment.
|
||||||
|
- **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow.
|
||||||
@@ -1,6 +1,23 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||||
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
+25
-12
@@ -9,11 +9,12 @@
|
|||||||
"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",
|
||||||
"refine": "refine"
|
"refine": "refine",
|
||||||
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.8.2",
|
||||||
@@ -24,12 +25,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 +38,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 +74,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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777523623582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11701" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" fill="#CE75FF" p-id="11702"></path><path d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" fill="#F3E2FF" p-id="11703"></path><path d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#F62E76" p-id="11704"></path><path d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#CD88FF" p-id="11705"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777457471585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5556" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M550.4 486.4c0-8.533333 4.266667-12.8 12.8-12.8h4.266667c4.266667 0 4.266667 4.266667 4.266666 4.266667s4.266667 4.266667 4.266667 8.533333v4.266667s0 4.266667-4.266667 4.266666c0 0-4.266667 0-4.266666 4.266667h-4.266667-4.266667s-4.266667 0-4.266666-4.266667c0 0 0-4.266667-4.266667-4.266666v-4.266667z" fill="#4D6BFE" p-id="5557"></path><path d="M994.133333 196.266667c-8.533333-4.266667-12.8 4.266667-21.333333 8.533333l-4.266667 4.266667c-12.8 17.066667-34.133333 25.6-55.466666 25.6-34.133333 0-59.733333 8.533333-85.333334 34.133333-4.266667-29.866667-21.333333-51.2-51.2-64-12.8-4.266667-29.866667-12.8-38.4-25.6-8.533333-8.533333-8.533333-21.333333-12.8-29.866667 0-4.266667 0-12.8-8.533333-12.8s-12.8 4.266667-12.8 12.8c-12.8 21.333333-21.333333 46.933333-17.066667 72.533334 0 59.733333 25.6 106.666667 72.533334 136.533333 4.266667 4.266667 8.533333 8.533333 4.266666 12.8-4.266667 12.8-8.533333 21.333333-8.533333 34.133333-4.266667 8.533333-4.266667 8.533333-12.8 4.266667-25.6-12.8-51.2-29.866667-68.266667-46.933333-34.133333-34.133333-64-72.533333-102.4-102.4-8.533333-8.533333-17.066667-12.8-25.6-21.333334-46.933333-34.133333 0-64 8.533334-68.266666 12.8-4.266667 4.266667-17.066667-29.866667-17.066667-34.133333 0-68.266667 12.8-106.666667 29.866667-8.533333 0-12.8 0-21.333333 4.266666-38.4-8.533333-76.8-8.533333-115.2-4.266666-76.8 8.533333-136.533333 42.666667-179.2 106.666666-51.2 76.8-64 157.866667-51.2 247.466667 17.066667 93.866667 64 170.666667 132.266667 230.4 72.533333 64 157.866667 93.866667 256 85.333333 59.733333-4.266667 123.733333-12.8 200.533333-76.8 17.066667 8.533333 38.4 12.8 72.533333 17.066667 25.6 4.266667 51.2 0 68.266667-4.266667 29.866667-4.266667 25.6-34.133333 17.066667-38.4-85.333333-42.666667-68.266667-25.6-85.333334-38.4 42.666667-51.2 110.933333-106.666667 136.533334-285.866666v-34.133334c0-8.533333 4.266667-8.533333 12.8-8.533333 21.333333-4.266667 42.666667-8.533333 59.733333-21.333333 55.466667-29.866667 76.8-81.066667 85.333333-145.066667 0-8.533333 0-17.066667-12.8-21.333333zM507.733333 746.666667c-85.333333-68.266667-123.733333-89.6-140.8-89.6-17.066667 0-12.8 21.333333-8.533333 29.866666 4.266667 12.8 8.533333 21.333333 12.8 29.866667 4.266667 8.533333 8.533333 17.066667-4.266667 25.6-25.6 17.066667-72.533333-4.266667-76.8-8.533333-55.466667-34.133333-98.133333-76.8-132.266666-136.533334-29.866667-51.2-46.933333-110.933333-46.933334-174.933333 0-17.066667 4.266667-21.333333 17.066667-25.6 21.333333-4.266667 42.666667-4.266667 59.733333 0 85.333333 12.8 157.866667 51.2 217.6 115.2 34.133333 34.133333 59.733333 76.8 89.6 119.466667 29.866667 42.666667 59.733333 85.333333 98.133334 119.466666 12.8 12.8 25.6 21.333333 34.133333 25.6-29.866667 0-81.066667 0-119.466667-29.866666z m166.4-196.266667c-8.533333 4.266667-17.066667 4.266667-25.6 4.266667-12.8 0-25.6-4.266667-29.866666-8.533334-12.8-8.533333-17.066667-12.8-21.333334-29.866666v-25.6c4.266667-12.8 0-21.333333-8.533333-29.866667-8.533333-4.266667-17.066667-8.533333-25.6-8.533333-4.266667 0-8.533333 0-8.533333-4.266667 0 0-4.266667 0-4.266667-4.266667v-4.266666-4.266667-4.266667c0-4.266667 8.533333-8.533333 8.533333-8.533333 12.8-8.533333 29.866667-4.266667 46.933334 0 12.8 4.266667 25.6 17.066667 38.4 29.866667 17.066667 17.066667 17.066667 25.6 25.6 38.4 8.533333 12.8 12.8 21.333333 17.066666 34.133333 0 12.8-4.266667 21.333333-12.8 25.6z" fill="#4D6BFE" p-id="5558"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh gitea v2026.05.15.1"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE="${1:-gitea}"
|
||||||
|
TAG="${2:-latest}"
|
||||||
|
|
||||||
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Current directory is not a git repository."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Remote '$REMOTE' does not exist."
|
||||||
|
echo "Available remotes:"
|
||||||
|
git remote -v
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
|
||||||
|
|
||||||
|
echo "[INFO] HEAD: ${HEAD_SHA}"
|
||||||
|
echo "[INFO] Recreate annotated tag '${TAG}'"
|
||||||
|
git tag -fa "$TAG" -m "$MESSAGE"
|
||||||
|
|
||||||
|
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
|
||||||
|
git push "$REMOTE" "refs/tags/${TAG}" --force
|
||||||
|
|
||||||
|
echo "[INFO] Verify remote tag reference"
|
||||||
|
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
|
||||||
|
|
||||||
|
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
|
||||||
@@ -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,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
|
<MapComponent>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<BurstPipeAnalysisPanel />
|
||||||
|
</MapComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
|
<MapComponent>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="contaminant_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<WaterQualityPanel />
|
||||||
|
</MapComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,251 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useMap } from "../MapComponent";
|
|
||||||
import TileLayer from "ol/layer/Tile.js";
|
|
||||||
import XYZ from "ol/source/XYZ.js";
|
|
||||||
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
|
|
||||||
import mapboxLight from "@assets/map/layers/mapbox-light.png";
|
|
||||||
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
|
|
||||||
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
|
|
||||||
import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Group from "ol/layer/Group";
|
|
||||||
import { MAPBOX_TOKEN } from "@config/config";
|
|
||||||
import { TIANDITU_TOKEN } from "@config/config";
|
|
||||||
const INITIAL_LAYER = "mapbox-light";
|
|
||||||
|
|
||||||
const streetsLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const lightMapLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const satelliteLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const satelliteStreetsLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
|
||||||
tileSize: 512,
|
|
||||||
maxZoom: 20,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions:
|
|
||||||
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tiandituVectorLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituVectorAnnotationLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituImageLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituImageAnnotationLayer = new TileLayer({
|
|
||||||
source: new XYZ({
|
|
||||||
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
|
||||||
projection: "EPSG:3857",
|
|
||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const tiandituVectorLayerGroup = new Group({
|
|
||||||
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
|
||||||
});
|
|
||||||
const tiandituImageLayerGroup = new Group({
|
|
||||||
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
|
||||||
});
|
|
||||||
const baseLayers = [
|
|
||||||
{
|
|
||||||
id: "mapbox-light",
|
|
||||||
name: "默认地图",
|
|
||||||
layer: lightMapLayer,
|
|
||||||
// layer: tiandituVectorLayerGroup,
|
|
||||||
img: mapboxLight.src,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mapbox-satellite",
|
|
||||||
name: "卫星地图",
|
|
||||||
layer: satelliteLayer,
|
|
||||||
// layer: tiandituImageLayerGroup,
|
|
||||||
img: mapboxSatellite.src,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mapbox-satellite-streets",
|
|
||||||
name: "卫星街道地图",
|
|
||||||
layer: satelliteStreetsLayer,
|
|
||||||
img: mapboxSatelliteStreet.src,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mapbox-streets",
|
|
||||||
name: "街道地图",
|
|
||||||
layer: streetsLayer,
|
|
||||||
img: mapboxStreets.src,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BaseLayers: React.FC = () => {
|
|
||||||
const map = useMap();
|
|
||||||
// 切换底图选项展开,控制显示和卸载
|
|
||||||
const [isShow, setShow] = useState(false);
|
|
||||||
const [isExpanded, setExpanded] = useState(false);
|
|
||||||
// 快速切换底图
|
|
||||||
const [activeId, setActiveId] = useState(INITIAL_LAYER);
|
|
||||||
|
|
||||||
// 初始化默认底图
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
// 添加所有底图至地图并根据 activeId 控制可见性
|
|
||||||
baseLayers.forEach((layerInfo) => {
|
|
||||||
const layers = map.getLayers().getArray();
|
|
||||||
if (!layers.includes(layerInfo.layer)) {
|
|
||||||
map.getLayers().insertAt(0, layerInfo.layer);
|
|
||||||
}
|
|
||||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
|
||||||
});
|
|
||||||
}, [map]);
|
|
||||||
|
|
||||||
const changeMapLayers = (id: string) => {
|
|
||||||
if (map) {
|
|
||||||
// 根据 id 设置每个图层的可见性
|
|
||||||
baseLayers.forEach(({ id: lid, layer }) => {
|
|
||||||
layer.setVisible(lid === id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickSwitch = () => {
|
|
||||||
const nextId =
|
|
||||||
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
|
|
||||||
setActiveId(nextId);
|
|
||||||
handleMapLayers(nextId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMapLayers = (id: string) => {
|
|
||||||
setActiveId(id);
|
|
||||||
changeMapLayers(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 记录定时器,避免多次触发
|
|
||||||
const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const handleEnter = () => {
|
|
||||||
if (hideTimer.current) {
|
|
||||||
clearTimeout(hideTimer.current);
|
|
||||||
hideTimer.current = null;
|
|
||||||
}
|
|
||||||
setShow(true);
|
|
||||||
setExpanded(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLeave = () => {
|
|
||||||
setShow(false);
|
|
||||||
hideTimer.current = setTimeout(() => {
|
|
||||||
setExpanded(false);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute right-17 bottom-8 z-1300">
|
|
||||||
<div
|
|
||||||
className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black"
|
|
||||||
onMouseEnter={handleEnter}
|
|
||||||
onMouseLeave={handleLeave}
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 p-1">
|
|
||||||
<button onClick={() => handleQuickSwitch()}>
|
|
||||||
<img
|
|
||||||
width={240}
|
|
||||||
height={100}
|
|
||||||
src={
|
|
||||||
activeId === baseLayers[0].id
|
|
||||||
? baseLayers[1].img
|
|
||||||
: baseLayers[0].img
|
|
||||||
}
|
|
||||||
alt={
|
|
||||||
activeId === baseLayers[0].id
|
|
||||||
? baseLayers[1].name
|
|
||||||
: baseLayers[0].name
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
<span>
|
|
||||||
{activeId === baseLayers[0].id
|
|
||||||
? baseLayers[1].name
|
|
||||||
: baseLayers[0].name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"absolute flex right-24 bottom-0 w-90 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
|
|
||||||
isShow ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
onMouseEnter={handleEnter}
|
|
||||||
onMouseLeave={handleLeave}
|
|
||||||
>
|
|
||||||
{baseLayers.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
|
|
||||||
onClick={() => handleMapLayers(item.id)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
width={240}
|
|
||||||
height={100}
|
|
||||||
src={item.img}
|
|
||||||
alt={item.name}
|
|
||||||
className={clsx(
|
|
||||||
"object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300",
|
|
||||||
{
|
|
||||||
"ring-1 ring-blue-300": activeId === item.id,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="pt-1">{item.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BaseLayers;
|
|
||||||
@@ -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,234 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface BaseProperty {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
unit?: string;
|
|
||||||
formatter?: (value: string | number) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增:表格型属性(用于二级数据)
|
|
||||||
interface TableProperty {
|
|
||||||
type: "table";
|
|
||||||
label: string;
|
|
||||||
columns: string[]; // 表头
|
|
||||||
rows: (string | number)[][]; // 每行的数据
|
|
||||||
}
|
|
||||||
|
|
||||||
type PropertyItem = BaseProperty | TableProperty;
|
|
||||||
|
|
||||||
interface PropertyPanelProps {
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
properties?: PropertyItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|
||||||
id,
|
|
||||||
type = "未知类型",
|
|
||||||
properties = [],
|
|
||||||
}) => {
|
|
||||||
const formatValue = (property: BaseProperty) => {
|
|
||||||
if (property.formatter) {
|
|
||||||
return property.formatter(property.value);
|
|
||||||
}
|
|
||||||
if (property.unit) {
|
|
||||||
return `${property.value} ${property.unit}`;
|
|
||||||
}
|
|
||||||
return property.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"];
|
|
||||||
|
|
||||||
// 统计属性数量(表格型按行数计入)
|
|
||||||
const totalProps = id
|
|
||||||
? 2 +
|
|
||||||
properties.reduce((sum, p) => {
|
|
||||||
if ("type" in p && p.type === "table") return sum + p.rows.length;
|
|
||||||
return sum + 1;
|
|
||||||
}, 0)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 ">
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<h3 className="text-lg font-semibold">属性面板</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
|
||||||
{!id ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
|
||||||
<svg
|
|
||||||
className="w-16 h-16 mb-3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm">暂无属性信息</p>
|
|
||||||
<p className="text-xs mt-1">请选择一个要素以查看其属性</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* ID 属性 */}
|
|
||||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
|
||||||
ID
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
|
||||||
{id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 类型属性 */}
|
|
||||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
|
||||||
类型
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
|
||||||
{type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 其他属性(包含二级表格) */}
|
|
||||||
{properties.map((property, index) => {
|
|
||||||
// 二级表格
|
|
||||||
if ("type" in property && property.type === "table") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`table-${index}`}
|
|
||||||
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
|
|
||||||
{property.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-gray-200 text-gray-700">
|
|
||||||
<tr>
|
|
||||||
{property.columns.map((col, ci) => (
|
|
||||||
<th
|
|
||||||
key={ci}
|
|
||||||
className="px-3 py-2 text-left font-semibold"
|
|
||||||
>
|
|
||||||
{col}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-300">
|
|
||||||
{property.rows.map((row, ri) => (
|
|
||||||
<tr key={ri} className="bg-white hover:bg-gray-50">
|
|
||||||
{row.map((cell, cci) => (
|
|
||||||
<td
|
|
||||||
key={cci}
|
|
||||||
className="px-3 py-2 text-gray-800"
|
|
||||||
>
|
|
||||||
{cell}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 普通属性
|
|
||||||
const base = property as BaseProperty;
|
|
||||||
const isImportant = isImportantKeys.includes(base.label);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`prop-${index}`}
|
|
||||||
className={`group rounded-lg p-3 transition-all duration-200 ${
|
|
||||||
isImportant
|
|
||||||
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
|
|
||||||
: "bg-gray-50 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start gap-3">
|
|
||||||
<span
|
|
||||||
className={`font-medium text-xs uppercase tracking-wide ${
|
|
||||||
isImportant ? "text-blue-700" : "text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{base.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-sm font-semibold text-right flex-1 ${
|
|
||||||
isImportant ? "text-blue-900" : "text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatValue(base)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部统计区域 */}
|
|
||||||
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-gray-600 flex items-center gap-1">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
共 {totalProps} 个属性
|
|
||||||
</span>
|
|
||||||
{id && (
|
|
||||||
<span className="text-green-600 flex items-center gap-1 font-medium">
|
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
|
||||||
已选中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PropertyPanel;
|
|
||||||
@@ -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;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,849 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { useData, useMap } from "../MapComponent";
|
|
||||||
import ToolbarButton from "@/components/olmap/common/ToolbarButton";
|
|
||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
||||||
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
|
|
||||||
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
|
|
||||||
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
|
||||||
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
|
||||||
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
|
||||||
|
|
||||||
import VectorSource from "ol/source/Vector";
|
|
||||||
import VectorLayer from "ol/layer/Vector";
|
|
||||||
import { Style, Stroke, Fill, Circle } from "ol/style";
|
|
||||||
import Feature from "ol/Feature";
|
|
||||||
import StyleEditorPanel from "./StyleEditorPanel";
|
|
||||||
import { LayerStyleState } from "./StyleEditorPanel";
|
|
||||||
import StyleLegend from "./StyleLegend"; // 引入图例组件
|
|
||||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
|
||||||
|
|
||||||
// 添加接口定义隐藏按钮的props
|
|
||||||
interface ToolbarProps {
|
|
||||||
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
|
|
||||||
queryType?: string; // 可选的查询类型参数
|
|
||||||
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
|
||||||
}
|
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
|
||||||
hiddenButtons,
|
|
||||||
queryType,
|
|
||||||
HistoryPanel,
|
|
||||||
}) => {
|
|
||||||
const map = useMap();
|
|
||||||
const data = useData();
|
|
||||||
const { open } = useNotification();
|
|
||||||
if (!data) return null;
|
|
||||||
const { currentTime, selectedDate, schemeName } = data;
|
|
||||||
const [activeTools, setActiveTools] = useState<string[]>([]);
|
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
|
||||||
const [showPropertyPanel, setShowPropertyPanel] = useState<boolean>(false);
|
|
||||||
const [showDrawPanel, setShowDrawPanel] = useState<boolean>(false);
|
|
||||||
const [showStyleEditor, setShowStyleEditor] = useState<boolean>(false);
|
|
||||||
const [showHistoryPanel, setShowHistoryPanel] = useState<boolean>(false);
|
|
||||||
const [highlightLayer, setHighlightLayer] =
|
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
|
||||||
|
|
||||||
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
|
|
||||||
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
|
|
||||||
{
|
|
||||||
isActive: false, // 默认不激活,不显示图例
|
|
||||||
layerId: "junctions",
|
|
||||||
layerName: "节点",
|
|
||||||
styleConfig: {
|
|
||||||
property: "pressure",
|
|
||||||
classificationMethod: "custom_breaks",
|
|
||||||
customBreaks: [16, 18, 20, 22, 24, 26],
|
|
||||||
customColors: [
|
|
||||||
"rgba(255, 0, 0, 1)",
|
|
||||||
"rgba(255, 127, 0, 1)",
|
|
||||||
"rgba(255, 215, 0, 1)",
|
|
||||||
"rgba(199, 224, 0, 1)",
|
|
||||||
"rgba(76, 175, 80, 1)",
|
|
||||||
"rgba(0, 158, 115, 1)",
|
|
||||||
],
|
|
||||||
segments: 6,
|
|
||||||
minSize: 4,
|
|
||||||
maxSize: 12,
|
|
||||||
minStrokeWidth: 2,
|
|
||||||
maxStrokeWidth: 8,
|
|
||||||
fixedStrokeWidth: 3,
|
|
||||||
colorType: "rainbow",
|
|
||||||
singlePaletteIndex: 0,
|
|
||||||
gradientPaletteIndex: 0,
|
|
||||||
rainbowPaletteIndex: 0,
|
|
||||||
showLabels: false,
|
|
||||||
showId: false,
|
|
||||||
opacity: 0.9,
|
|
||||||
adjustWidthByProperty: true,
|
|
||||||
},
|
|
||||||
legendConfig: {
|
|
||||||
layerId: "junctions",
|
|
||||||
layerName: "节点",
|
|
||||||
property: "压力", // 暂时为空,等计算后更新
|
|
||||||
colors: [],
|
|
||||||
type: "point",
|
|
||||||
dimensions: [],
|
|
||||||
breaks: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isActive: false, // 默认不激活,不显示图例
|
|
||||||
layerId: "pipes",
|
|
||||||
layerName: "管道",
|
|
||||||
styleConfig: {
|
|
||||||
property: "flow",
|
|
||||||
classificationMethod: "pretty_breaks",
|
|
||||||
segments: 6,
|
|
||||||
minSize: 4,
|
|
||||||
maxSize: 12,
|
|
||||||
minStrokeWidth: 2,
|
|
||||||
maxStrokeWidth: 8,
|
|
||||||
fixedStrokeWidth: 3,
|
|
||||||
colorType: "gradient",
|
|
||||||
singlePaletteIndex: 0,
|
|
||||||
gradientPaletteIndex: 0,
|
|
||||||
rainbowPaletteIndex: 0,
|
|
||||||
showLabels: false,
|
|
||||||
showId: false,
|
|
||||||
opacity: 0.9,
|
|
||||||
adjustWidthByProperty: true,
|
|
||||||
},
|
|
||||||
legendConfig: {
|
|
||||||
layerId: "pipes",
|
|
||||||
layerName: "管道",
|
|
||||||
property: "流量", // 暂时为空,等计算后更新
|
|
||||||
colors: [],
|
|
||||||
type: "linestring",
|
|
||||||
dimensions: [],
|
|
||||||
breaks: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 计算激活的图例配置
|
|
||||||
const activeLegendConfigs = layerStyleStates
|
|
||||||
.filter((state) => state.isActive && state.legendConfig.property)
|
|
||||||
.map((state) => ({
|
|
||||||
...state.legendConfig,
|
|
||||||
layerName: state.layerName,
|
|
||||||
layerId: state.layerId,
|
|
||||||
}));
|
|
||||||
// 创建高亮图层
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const highLightSource = new VectorSource();
|
|
||||||
const highLightLayer = new VectorLayer({
|
|
||||||
source: highLightSource,
|
|
||||||
style: new Style({
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: `rgba(255, 0, 0, 1)`,
|
|
||||||
width: 5,
|
|
||||||
}),
|
|
||||||
fill: new Fill({
|
|
||||||
color: `rgba(255, 0, 0, 0.2)`,
|
|
||||||
}),
|
|
||||||
image: new Circle({
|
|
||||||
radius: 7,
|
|
||||||
stroke: new Stroke({
|
|
||||||
color: `rgba(255, 0, 0, 1)`,
|
|
||||||
width: 3,
|
|
||||||
}),
|
|
||||||
fill: new Fill({
|
|
||||||
color: `rgba(255, 0, 0, 0.2)`,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
properties: {
|
|
||||||
name: "属性查询高亮图层", // 设置图层名称
|
|
||||||
value: "info_highlight_layer",
|
|
||||||
type: "multigeometry",
|
|
||||||
properties: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
map.addLayer(highLightLayer);
|
|
||||||
setHighlightLayer(highLightLayer);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.removeLayer(highLightLayer);
|
|
||||||
};
|
|
||||||
}, [map]);
|
|
||||||
// 高亮要素的函数
|
|
||||||
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 handleMapClickSelectFeatures = useCallback(
|
|
||||||
async (event: { coordinate: number[] }) => {
|
|
||||||
if (!map) return;
|
|
||||||
const feature = await mapClickSelectFeatures(event, map); // 调用导入的函数
|
|
||||||
|
|
||||||
if (!feature || !(feature instanceof Feature)) {
|
|
||||||
// 如果没有点击到要素,且当前是 info 模式,则清除高亮
|
|
||||||
if (activeTools.includes("info")) {
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTools.includes("history")) {
|
|
||||||
// 历史查询模式:支持同类型多选
|
|
||||||
const featureId = feature.getProperties().id;
|
|
||||||
const layerId = feature.getId()?.toString().split(".")[0] || "";
|
|
||||||
console.log("点击选择要素", feature, "图层:", layerId);
|
|
||||||
// 简单的类型检查函数
|
|
||||||
const getBaseType = (lid: string) => {
|
|
||||||
if (lid.includes("pipe")) return "pipe";
|
|
||||||
if (lid.includes("junction")) return "junction";
|
|
||||||
if (lid.includes("tank")) return "tank";
|
|
||||||
if (lid.includes("reservoir")) return "reservoir";
|
|
||||||
if (lid.includes("pump")) return "pump";
|
|
||||||
if (lid.includes("valve")) return "valve";
|
|
||||||
return lid;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否与已选要素类型一致
|
|
||||||
if (highlightFeatures.length > 0) {
|
|
||||||
const firstLayerId =
|
|
||||||
highlightFeatures[0].getId()?.toString().split(".")[0] || "";
|
|
||||||
|
|
||||||
if (getBaseType(layerId) !== getBaseType(firstLayerId)) {
|
|
||||||
// 如果点击的是已选中的要素(为了取消选中),则不报错
|
|
||||||
const isAlreadySelected = highlightFeatures.some(
|
|
||||||
(f) => f.getProperties().id === featureId,
|
|
||||||
);
|
|
||||||
if (!isAlreadySelected) {
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "请选择相同类型的要素进行多选查询。",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHighlightFeatures((prev) => {
|
|
||||||
const existingIndex = prev.findIndex(
|
|
||||||
(f) => f.getProperties().id === featureId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
|
||||||
// 如果已存在,移除
|
|
||||||
return prev.filter((_, i) => i !== existingIndex);
|
|
||||||
} else {
|
|
||||||
// 如果不存在,添加
|
|
||||||
return [...prev, feature];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 其他模式(如 info):单选
|
|
||||||
setHighlightFeatures([feature]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[map, activeTools, highlightFeatures, open],
|
|
||||||
);
|
|
||||||
// 添加矢量属性查询事件监听器
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
// 监听 info 或 history 工具激活时添加
|
|
||||||
if (activeTools.includes("info") || activeTools.includes("history")) {
|
|
||||||
map.on("click", handleMapClickSelectFeatures);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.un("click", handleMapClickSelectFeatures);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [activeTools, map, handleMapClickSelectFeatures]);
|
|
||||||
|
|
||||||
// 处理工具栏按钮点击事件
|
|
||||||
const handleToolClick = (tool: string) => {
|
|
||||||
// 样式工具的特殊处理 - 只有再次点击时才会取消激活和关闭
|
|
||||||
if (tool === "style") {
|
|
||||||
if (activeTools.includes("style")) {
|
|
||||||
// 如果样式工具已激活,点击时关闭
|
|
||||||
setShowStyleEditor(false);
|
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== "style"));
|
|
||||||
} else {
|
|
||||||
// 激活样式工具,打开样式面板
|
|
||||||
setActiveTools((prev) => [...prev, "style"]);
|
|
||||||
setShowStyleEditor(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他工具的处理逻辑
|
|
||||||
if (activeTools.includes(tool)) {
|
|
||||||
// 如果当前工具已激活,再次点击时取消激活并关闭面板
|
|
||||||
deactivateTool(tool);
|
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== tool));
|
|
||||||
} else {
|
|
||||||
// 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具
|
|
||||||
// 关闭所有面板(但保持样式编辑器状态)
|
|
||||||
closeAllPanelsExceptStyle();
|
|
||||||
|
|
||||||
// 取消激活所有非样式工具
|
|
||||||
setActiveTools((prev) => {
|
|
||||||
const styleActive = prev.includes("style");
|
|
||||||
return styleActive ? ["style", tool] : [tool];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 激活当前工具并打开对应面板
|
|
||||||
activateTool(tool);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 取消激活指定工具并关闭对应面板
|
|
||||||
const deactivateTool = (tool: string) => {
|
|
||||||
switch (tool) {
|
|
||||||
case "info":
|
|
||||||
setShowPropertyPanel(false);
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
break;
|
|
||||||
case "draw":
|
|
||||||
setShowDrawPanel(false);
|
|
||||||
break;
|
|
||||||
case "history":
|
|
||||||
setShowHistoryPanel(false);
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 激活指定工具并打开对应面板
|
|
||||||
const activateTool = (tool: string) => {
|
|
||||||
switch (tool) {
|
|
||||||
case "info":
|
|
||||||
setShowPropertyPanel(true);
|
|
||||||
break;
|
|
||||||
case "draw":
|
|
||||||
setShowDrawPanel(true);
|
|
||||||
break;
|
|
||||||
case "history":
|
|
||||||
setShowHistoryPanel(true);
|
|
||||||
// 激活历史查询后:HistoryDataPanel 自行负责根据传入的 props 拉取数据。
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 关闭所有面板(除了样式编辑器)
|
|
||||||
const closeAllPanelsExceptStyle = () => {
|
|
||||||
setShowPropertyPanel(false);
|
|
||||||
setHighlightFeatures([]);
|
|
||||||
setShowDrawPanel(false);
|
|
||||||
setShowHistoryPanel(false);
|
|
||||||
// 样式编辑器保持其当前状态,不自动关闭
|
|
||||||
};
|
|
||||||
const [computedProperties, setComputedProperties] = useState<
|
|
||||||
Record<string, any>
|
|
||||||
>({});
|
|
||||||
// 添加 useEffect 来查询计算属性
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
|
|
||||||
setComputedProperties({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightFeature = highlightFeatures[0];
|
|
||||||
const id = highlightFeature.getProperties().id;
|
|
||||||
if (!id) {
|
|
||||||
setComputedProperties({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryComputedProperties = async () => {
|
|
||||||
try {
|
|
||||||
const properties = highlightFeature?.getProperties?.() || {};
|
|
||||||
const type =
|
|
||||||
properties.geometry?.getType?.() === "LineString" ? "link" : "node";
|
|
||||||
// selectedDate 格式化为 YYYY-MM-DD
|
|
||||||
let dateObj: Date;
|
|
||||||
if (selectedDate instanceof Date) {
|
|
||||||
dateObj = new Date(selectedDate);
|
|
||||||
} else {
|
|
||||||
dateObj = new Date(selectedDate);
|
|
||||||
}
|
|
||||||
const minutes = Number(currentTime) || 0;
|
|
||||||
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
||||||
// 转为 UTC ISO 字符串
|
|
||||||
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
|
||||||
let response;
|
|
||||||
if (queryType === "scheme") {
|
|
||||||
response = await fetch(
|
|
||||||
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
|
||||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
response = await fetch(
|
|
||||||
// `${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}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("API request failed");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setComputedProperties(data.results[0] || {});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error querying computed properties:", error);
|
|
||||||
setComputedProperties({});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 仅当 currentTime 有效时查询
|
|
||||||
if (currentTime !== -1 && queryType) queryComputedProperties();
|
|
||||||
}, [highlightFeatures, currentTime, selectedDate]);
|
|
||||||
|
|
||||||
// 从要素属性中提取属性面板需要的数据
|
|
||||||
const getFeatureProperties = useCallback(() => {
|
|
||||||
if (highlightFeatures.length === 0) return {};
|
|
||||||
const highlightFeature = highlightFeatures[0];
|
|
||||||
const layer = highlightFeature?.getId()?.toString().split(".")[0];
|
|
||||||
const properties = highlightFeature.getProperties();
|
|
||||||
// 计算属性字段,增加 key 字段
|
|
||||||
const pipeComputedFields = [
|
|
||||||
{ key: "flow", label: "流量", unit: "m³/h" },
|
|
||||||
{ key: "friction", label: "摩阻", unit: "" },
|
|
||||||
{ key: "headloss", label: "水头损失", unit: "m" },
|
|
||||||
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
|
|
||||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
|
||||||
{ key: "reaction", label: "反应", unit: "1/d" },
|
|
||||||
{ key: "setting", label: "设置", unit: "" },
|
|
||||||
{ key: "status", label: "状态", unit: "" },
|
|
||||||
{ key: "velocity", label: "流速", unit: "m/s" },
|
|
||||||
];
|
|
||||||
const nodeComputedFields = [
|
|
||||||
{ key: "actual_demand", label: "实际需水量", unit: "m³/h" },
|
|
||||||
{ key: "total_head", label: "水头", unit: "m" },
|
|
||||||
{ key: "pressure", label: "压力", unit: "m" },
|
|
||||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
|
|
||||||
let result = {
|
|
||||||
id: properties.id,
|
|
||||||
type: "管道",
|
|
||||||
properties: [
|
|
||||||
{ label: "起始节点ID", value: properties.node1 },
|
|
||||||
{ label: "终点节点ID", value: properties.node2 },
|
|
||||||
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
|
|
||||||
{
|
|
||||||
label: "管径",
|
|
||||||
value: properties.diameter?.toFixed?.(1),
|
|
||||||
unit: "mm",
|
|
||||||
},
|
|
||||||
{ label: "粗糙度", value: properties.roughness },
|
|
||||||
{ label: "局部损失", value: properties.minor_loss },
|
|
||||||
{ label: "初始状态", value: "开" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
// 追加计算属性
|
|
||||||
if (computedProperties) {
|
|
||||||
pipeComputedFields.forEach(({ key, label, unit }) => {
|
|
||||||
let value = computedProperties[key];
|
|
||||||
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
|
|
||||||
if (
|
|
||||||
key === "unit_headloss" &&
|
|
||||||
value === undefined &&
|
|
||||||
computedProperties.headloss !== undefined &&
|
|
||||||
properties.length
|
|
||||||
) {
|
|
||||||
value = (computedProperties.headloss / properties.length) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined) {
|
|
||||||
result.properties.push({
|
|
||||||
label,
|
|
||||||
value: typeof value === "number" ? value.toFixed(3) : value,
|
|
||||||
unit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
|
|
||||||
let result = {
|
|
||||||
id: properties.id,
|
|
||||||
type: "节点",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "高程",
|
|
||||||
value: properties.elevation?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
// 将 demand1~demand5 与 pattern1~pattern5 作为二级表格展示
|
|
||||||
{
|
|
||||||
type: "table",
|
|
||||||
label: "基本需水量",
|
|
||||||
columns: ["demand", "pattern"],
|
|
||||||
rows: Array.from({ length: 5 }, (_, i) => i + 1)
|
|
||||||
.map((idx) => {
|
|
||||||
const d = properties?.[`demand${idx}`]?.toFixed?.(3);
|
|
||||||
const p = properties?.[`pattern${idx}`];
|
|
||||||
// 仅当 demand 有效时展示该行
|
|
||||||
if (d !== undefined && d !== null && d !== "") {
|
|
||||||
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean) as (string | number)[][],
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
// 追加计算属性
|
|
||||||
if (computedProperties) {
|
|
||||||
nodeComputedFields.forEach(({ key, label, unit }) => {
|
|
||||||
if (computedProperties[key] !== undefined) {
|
|
||||||
result.properties.push({
|
|
||||||
label,
|
|
||||||
value:
|
|
||||||
computedProperties[key].toFixed?.(3) || computedProperties[key],
|
|
||||||
unit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "水池",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "高程",
|
|
||||||
value: properties.elevation?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "初始水位",
|
|
||||||
value: properties.init_level?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "最低水位",
|
|
||||||
value: properties.min_level?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "最高水位",
|
|
||||||
value: properties.max_level?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "直径",
|
|
||||||
value: properties.diameter?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "最小容积",
|
|
||||||
value: properties.min_vol?.toFixed?.(1),
|
|
||||||
unit: "m³",
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// label: "容积曲线",
|
|
||||||
// value: properties.vol_curve,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
label: "溢出",
|
|
||||||
value: properties.overflow ? "是" : "否",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "水库",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "水头",
|
|
||||||
value: properties.head?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// label: "模式",
|
|
||||||
// value: properties.pattern,
|
|
||||||
// },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "水泵",
|
|
||||||
properties: [
|
|
||||||
{ label: "起始节点 ID", value: properties.node1 },
|
|
||||||
{ label: "终点节点 ID", value: properties.node2 },
|
|
||||||
{
|
|
||||||
label: "功率",
|
|
||||||
value: properties.power?.toFixed?.(1),
|
|
||||||
unit: "kW",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "扬程",
|
|
||||||
value: properties.head?.toFixed?.(1),
|
|
||||||
unit: "m",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "转速",
|
|
||||||
value: properties.speed?.toFixed?.(1),
|
|
||||||
unit: "rpm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "模式",
|
|
||||||
value: properties.pattern,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (layer === "geo_valves_mat" || layer === "geo_valves") {
|
|
||||||
return {
|
|
||||||
id: properties.id,
|
|
||||||
type: "阀门",
|
|
||||||
properties: [
|
|
||||||
{ label: "起始节点 ID", value: properties.node1 },
|
|
||||||
{ label: "终点节点 ID", value: properties.node2 },
|
|
||||||
{
|
|
||||||
label: "直径",
|
|
||||||
value: properties.diameter?.toFixed?.(1),
|
|
||||||
unit: "mm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "阀门类型",
|
|
||||||
value: properties.v_type,
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// label: "设置",
|
|
||||||
// value: properties.setting?.toFixed?.(2),
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
label: "局部损失",
|
|
||||||
value: properties.minor_loss?.toFixed?.(2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 传输频率文字对应
|
|
||||||
const getTransmissionFrequency = (transmission_frequency: string) => {
|
|
||||||
// 传输频率文本:00:01:00,00:05:00,00:10:00,00:30:00,01:00:00,转换为分钟数
|
|
||||||
const parts = transmission_frequency.split(":");
|
|
||||||
if (parts.length !== 3) return transmission_frequency;
|
|
||||||
const hours = parseInt(parts[0], 10);
|
|
||||||
const minutes = parseInt(parts[1], 10);
|
|
||||||
const seconds = parseInt(parts[2], 10);
|
|
||||||
const totalMinutes = hours * 60 + minutes + (seconds >= 30 ? 1 : 0);
|
|
||||||
return totalMinutes;
|
|
||||||
};
|
|
||||||
// 可靠度文字映射
|
|
||||||
const getReliability = (reliability: number) => {
|
|
||||||
switch (reliability) {
|
|
||||||
case 1:
|
|
||||||
return "高";
|
|
||||||
case 2:
|
|
||||||
return "中";
|
|
||||||
case 3:
|
|
||||||
return "低";
|
|
||||||
default:
|
|
||||||
return "未知";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (layer === "geo_scada_mat" || layer === "geo_scada") {
|
|
||||||
let result = {
|
|
||||||
id: properties.id,
|
|
||||||
type: "SCADA设备",
|
|
||||||
properties: [
|
|
||||||
{
|
|
||||||
label: "类型",
|
|
||||||
value:
|
|
||||||
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "关联节点 ID",
|
|
||||||
value: properties.associated_element_id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "传输模式",
|
|
||||||
value:
|
|
||||||
properties.transmission_mode === "non_realtime"
|
|
||||||
? "定时传输"
|
|
||||||
: "实时传输",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "传输频率",
|
|
||||||
value: getTransmissionFrequency(properties.transmission_frequency),
|
|
||||||
unit: "分钟",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "可靠性",
|
|
||||||
value: getReliability(properties.reliability),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}, [highlightFeatures, computedProperties]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity">
|
|
||||||
{!hiddenButtons?.includes("info") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<InfoOutlinedIcon />}
|
|
||||||
name="查看属性"
|
|
||||||
isActive={activeTools.includes("info")}
|
|
||||||
onClick={() => handleToolClick("info")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hiddenButtons?.includes("history") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<QueryStatsOutlinedIcon />}
|
|
||||||
name="查询历史数据"
|
|
||||||
isActive={activeTools.includes("history")}
|
|
||||||
onClick={() => handleToolClick("history")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hiddenButtons?.includes("draw") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<EditOutlinedIcon />}
|
|
||||||
name="标记绘制"
|
|
||||||
isActive={activeTools.includes("draw")}
|
|
||||||
onClick={() => handleToolClick("draw")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hiddenButtons?.includes("style") && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<PaletteOutlinedIcon />}
|
|
||||||
name="图层样式"
|
|
||||||
isActive={activeTools.includes("style")}
|
|
||||||
onClick={() => handleToolClick("style")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
|
|
||||||
{showDrawPanel && map && <DrawPanel />}
|
|
||||||
<div style={{ display: showStyleEditor ? "block" : "none" }}>
|
|
||||||
<StyleEditorPanel
|
|
||||||
layerStyleStates={layerStyleStates}
|
|
||||||
setLayerStyleStates={setLayerStyleStates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showHistoryPanel &&
|
|
||||||
(HistoryPanel ? (
|
|
||||||
<HistoryPanel
|
|
||||||
featureInfos={(() => {
|
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
return highlightFeatures
|
|
||||||
.map((feature) => {
|
|
||||||
const properties = feature.getProperties();
|
|
||||||
const id = properties.id;
|
|
||||||
if (!id) return null;
|
|
||||||
|
|
||||||
// 从图层名称推断类型
|
|
||||||
const layerId =
|
|
||||||
feature.getId()?.toString().split(".")[0] || "";
|
|
||||||
let type = "unknown";
|
|
||||||
|
|
||||||
if (layerId.includes("pipe")) {
|
|
||||||
type = "pipe";
|
|
||||||
} else if (layerId.includes("junction")) {
|
|
||||||
type = "junction";
|
|
||||||
} else if (layerId.includes("tank")) {
|
|
||||||
type = "tank";
|
|
||||||
} else if (layerId.includes("reservoir")) {
|
|
||||||
type = "reservoir";
|
|
||||||
} else if (layerId.includes("pump")) {
|
|
||||||
type = "pump";
|
|
||||||
} else if (layerId.includes("valve")) {
|
|
||||||
type = "valve";
|
|
||||||
}
|
|
||||||
// 仅处理 type 为 pipe 或 junction 的情况
|
|
||||||
if (type !== "pipe" && type !== "junction") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return [id, type];
|
|
||||||
})
|
|
||||||
.filter(Boolean) as [string, string][];
|
|
||||||
})()}
|
|
||||||
scheme_type="burst_Analysis"
|
|
||||||
scheme_name={schemeName}
|
|
||||||
type={queryType as "realtime" | "scheme" | "none"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<HistoryDataPanel
|
|
||||||
featureInfos={(() => {
|
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
return highlightFeatures
|
|
||||||
.map((feature) => {
|
|
||||||
const properties = feature.getProperties();
|
|
||||||
const id = properties.id;
|
|
||||||
if (!id) return null;
|
|
||||||
|
|
||||||
// 从图层名称推断类型
|
|
||||||
const layerId =
|
|
||||||
feature.getId()?.toString().split(".")[0] || "";
|
|
||||||
let type = "unknown";
|
|
||||||
|
|
||||||
if (layerId.includes("pipe")) {
|
|
||||||
type = "pipe";
|
|
||||||
} else if (layerId.includes("junction")) {
|
|
||||||
type = "junction";
|
|
||||||
} else if (layerId.includes("tank")) {
|
|
||||||
type = "tank";
|
|
||||||
} else if (layerId.includes("reservoir")) {
|
|
||||||
type = "reservoir";
|
|
||||||
} else if (layerId.includes("pump")) {
|
|
||||||
type = "pump";
|
|
||||||
} else if (layerId.includes("valve")) {
|
|
||||||
type = "valve";
|
|
||||||
}
|
|
||||||
// 仅处理 type 为 pipe 或 junction 的情况
|
|
||||||
if (type !== "pipe" && type !== "junction") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return [id, type];
|
|
||||||
})
|
|
||||||
.filter(Boolean) as [string, string][];
|
|
||||||
})()}
|
|
||||||
scheme_type="burst_Analysis"
|
|
||||||
scheme_name={schemeName}
|
|
||||||
type={queryType as "realtime" | "scheme" | "none"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 图例显示 */}
|
|
||||||
{activeLegendConfigs.length > 0 && (
|
|
||||||
<div className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
|
||||||
<div className="flex flex-row gap-3">
|
|
||||||
{activeLegendConfigs.map((config, index) => (
|
|
||||||
<StyleLegend key={`${config.layerId}-${index}`} {...config} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Toolbar;
|
|
||||||
+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;
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import BarChartRounded from "@mui/icons-material/BarChartRounded";
|
||||||
|
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
|
||||||
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
|
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
||||||
|
|
||||||
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
|
import type { ChatChartSeries } from "./ChatInlineChart";
|
||||||
|
import type { AgentArtifact } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const artifactIcon = (kind: AgentArtifact["kind"]) => {
|
||||||
|
if (kind === "chart") return <BarChartRounded sx={{ fontSize: 18 }} />;
|
||||||
|
if (kind === "map") return <LocationOnRounded sx={{ fontSize: 18 }} />;
|
||||||
|
if (kind === "panel") return <SensorsRounded sx={{ fontSize: 18 }} />;
|
||||||
|
return <BuildCircleRounded sx={{ fontSize: 18 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const artifactColor = (kind: AgentArtifact["kind"], theme: Theme) => {
|
||||||
|
if (kind === "chart") return theme.palette.info.main;
|
||||||
|
if (kind === "map") return theme.palette.success.main;
|
||||||
|
if (kind === "panel") return theme.palette.warning.main;
|
||||||
|
return theme.palette.primary.main;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
if (!artifacts.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1.25}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="caption" fontWeight={800} color="text.primary">
|
||||||
|
结果与动作
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${artifacts.length} 项`}
|
||||||
|
sx={{ height: 20, fontSize: "0.68rem" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const color = artifactColor(artifact.kind, theme);
|
||||||
|
if (artifact.kind === "chart") {
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={artifact.id}
|
||||||
|
title={(artifact.params.title as string) ?? artifact.title}
|
||||||
|
chart_type={
|
||||||
|
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
|
}
|
||||||
|
x_data={(artifact.params.x_data as string[]) ?? []}
|
||||||
|
series={(artifact.params.series as ChatChartSeries[]) ?? []}
|
||||||
|
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={artifact.id}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 1.35,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(color, 0.22)}`,
|
||||||
|
bgcolor: alpha(color, 0.055),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(color, 0.12),
|
||||||
|
color,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artifactIcon(artifact.kind)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="caption" fontWeight={800} color="text.primary">
|
||||||
|
{artifact.title}
|
||||||
|
</Typography>
|
||||||
|
{artifact.description ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{artifact.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="已执行"
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
bgcolor: alpha(color, 0.12),
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Collapse,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import SendRounded from "@mui/icons-material/SendRounded";
|
||||||
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
|
import MicRounded from "@mui/icons-material/MicRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
||||||
|
import BoltRounded from "@mui/icons-material/BoltRounded";
|
||||||
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
|
import type { AgentModel } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
export type AgentComposerHandle = {
|
||||||
|
focus: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
append: (text: string) => void;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
getValue: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentComposerProps = {
|
||||||
|
isHydrating?: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
isListening: boolean;
|
||||||
|
isSttSupported: boolean;
|
||||||
|
presets: string[];
|
||||||
|
onSend: (prompt: string) => void;
|
||||||
|
onAbort: () => void;
|
||||||
|
onStartListening: () => void;
|
||||||
|
onStopListening: () => void;
|
||||||
|
selectedModel: AgentModel;
|
||||||
|
onModelChange: (model: AgentModel) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||||
|
isHydrating = false,
|
||||||
|
isStreaming,
|
||||||
|
isListening,
|
||||||
|
isSttSupported,
|
||||||
|
presets,
|
||||||
|
onSend,
|
||||||
|
onAbort,
|
||||||
|
onStartListening,
|
||||||
|
onStopListening,
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
|
}, ref) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||||
|
const [input, setInput] = React.useState("");
|
||||||
|
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
||||||
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
||||||
|
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
focus: () => inputRef.current?.focus(),
|
||||||
|
clear: () => setInput(""),
|
||||||
|
append: (text: string) => setInput((prev) => prev + text),
|
||||||
|
setValue: (value: string) => setInput(value),
|
||||||
|
getValue: () => input,
|
||||||
|
}),
|
||||||
|
[input],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSend = React.useCallback(() => {
|
||||||
|
const prompt = input.trim();
|
||||||
|
if (!prompt || isStreaming || isHydrating) return;
|
||||||
|
setInput("");
|
||||||
|
onSend(prompt);
|
||||||
|
}, [input, isHydrating, isStreaming, onSend]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
||||||
|
<Paper
|
||||||
|
elevation={isPresetOpen ? 4 : 0}
|
||||||
|
sx={{
|
||||||
|
mb: 1.5,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.5)}`,
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
boxShadow: isPresetOpen ? `0 -8px 24px ${alpha("#00acc1", 0.1)}` : "none",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Image
|
||||||
|
src="/ai-agent.svg"
|
||||||
|
alt="TJWater Agent"
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
style={{
|
||||||
|
objectFit: "contain",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
|
管网分析快捷指令
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsPresetOpen((value) => !value)}
|
||||||
|
aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
|
||||||
|
sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }}
|
||||||
|
>
|
||||||
|
{isPresetOpen ? (
|
||||||
|
<KeyboardArrowDownRounded fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowUpRounded fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Collapse in={isPresetOpen} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ mt: 1.5, mb: 0.5, pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{presets.map((prompt) => (
|
||||||
|
<Chip
|
||||||
|
key={prompt}
|
||||||
|
label={prompt.replace(/[。.]$/, "")}
|
||||||
|
size="medium"
|
||||||
|
clickable
|
||||||
|
onClick={() => {
|
||||||
|
setInput(prompt);
|
||||||
|
setIsPresetOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "16px",
|
||||||
|
bgcolor: alpha("#fff", 0.7),
|
||||||
|
border: `1px solid ${alpha("#00acc1", 0.15)}`,
|
||||||
|
color: "text.primary",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
boxShadow: `0 2px 6px ${alpha("#000", 0.03)}`,
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#fff", 0.95),
|
||||||
|
boxShadow: `0 4px 10px ${alpha("#00acc1", 0.2)}`,
|
||||||
|
borderColor: alpha("#00acc1", 0.4),
|
||||||
|
color: "#00acc1"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}>
|
||||||
|
<Paper
|
||||||
|
elevation={12}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.75),
|
||||||
|
backdropFilter: "blur(40px)",
|
||||||
|
border: `1px solid ${alpha("#ffffff", 0.9)}`,
|
||||||
|
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
inputRef={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
maxRows={5}
|
||||||
|
variant="standard"
|
||||||
|
disabled={isHydrating}
|
||||||
|
InputProps={{
|
||||||
|
disableUnderline: true,
|
||||||
|
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
|
||||||
|
<AttachFileRounded fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
{isSttSupported ? (
|
||||||
|
isListening ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.14, 1] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={onStopListening}
|
||||||
|
aria-label="停止语音输入"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
color: "error.main",
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.15),
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MicRounded fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
onClick={onStartListening}
|
||||||
|
disabled={isStreaming || isHydrating}
|
||||||
|
aria-label="语音输入"
|
||||||
|
size="small"
|
||||||
|
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||||
|
>
|
||||||
|
<MicRounded fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||||||
|
<Select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
||||||
|
disabled={isHydrating || isStreaming}
|
||||||
|
aria-label="模型选择"
|
||||||
|
renderValue={(val) => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{val === "deepseek/deepseek-v4-flash" ? (
|
||||||
|
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
|
||||||
|
)}
|
||||||
|
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
||||||
|
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
MenuProps={{
|
||||||
|
anchorOrigin: { vertical: "top", horizontal: "center" },
|
||||||
|
transformOrigin: { vertical: "bottom", horizontal: "center" },
|
||||||
|
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
|
||||||
|
PaperProps: {
|
||||||
|
sx: {
|
||||||
|
mb: 1.5,
|
||||||
|
width: 230,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.85),
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||||
|
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||||
|
"& .MuiList-root": {
|
||||||
|
p: 1,
|
||||||
|
},
|
||||||
|
"& .MuiMenuItem-root": {
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.2,
|
||||||
|
mb: 0.5,
|
||||||
|
"&:last-child": { mb: 0 },
|
||||||
|
borderRadius: 3,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#000", 0.03),
|
||||||
|
},
|
||||||
|
"&.Mui-selected": {
|
||||||
|
bgcolor: alpha("#00acc1", 0.08),
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#00acc1", 0.12),
|
||||||
|
},
|
||||||
|
"& .title": { color: "#00838f" },
|
||||||
|
"& .icon": { color: "#00acc1" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "18px",
|
||||||
|
bgcolor: "transparent",
|
||||||
|
color: "text.secondary",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
".MuiOutlinedInput-notchedOutline": {
|
||||||
|
border: "none",
|
||||||
|
},
|
||||||
|
".MuiSelect-select": {
|
||||||
|
py: 0,
|
||||||
|
pl: 1,
|
||||||
|
pr: "28px !important",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
|
||||||
|
bgcolor: alpha("#000", 0.06),
|
||||||
|
color: "text.primary",
|
||||||
|
".MuiSelect-icon": {
|
||||||
|
color: "text.primary",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
".MuiSelect-icon": {
|
||||||
|
color: "text.secondary",
|
||||||
|
right: 4,
|
||||||
|
transition: "color 0.2s ease",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src="/deepseek-logo.svg"
|
||||||
|
alt="DeepSeek"
|
||||||
|
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
||||||
|
DEEPSEEK V4
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<MenuItem value="deepseek/deepseek-v4-flash">
|
||||||
|
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
|
||||||
|
<Box>
|
||||||
|
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography>
|
||||||
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="deepseek/deepseek-v4-pro">
|
||||||
|
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
|
||||||
|
<Box>
|
||||||
|
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>专家</Typography>
|
||||||
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isStreaming ? (
|
||||||
|
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onAbort}
|
||||||
|
aria-label="停止生成"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: "error.main",
|
||||||
|
color: "#fff",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`,
|
||||||
|
"&:hover": { bgcolor: "error.dark" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StopRounded />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||||
|
<IconButton
|
||||||
|
disabled={!canSend}
|
||||||
|
onClick={handleSend}
|
||||||
|
aria-label="发送"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5),
|
||||||
|
color: canSend ? "#fff" : "action.disabled",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none",
|
||||||
|
"&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SendRounded sx={{ ml: 0.35 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, opacity: 0.6 }}>
|
||||||
|
<Image
|
||||||
|
src="/deepseek-logo.svg"
|
||||||
|
alt="DeepSeek"
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
style={{ width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}>
|
||||||
|
Powered by DeepSeek V4 · TJWater Agent Intelligence
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CheckRounded from "@mui/icons-material/CheckRounded";
|
||||||
|
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||||
|
import EditRounded from "@mui/icons-material/EditRounded";
|
||||||
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
|
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
||||||
|
|
||||||
|
type AgentHeaderProps = {
|
||||||
|
sessionTitle?: string;
|
||||||
|
canRenameSessionTitle?: boolean;
|
||||||
|
isHydrating?: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
isHistoryOpen: boolean;
|
||||||
|
onHistoryToggle: () => void;
|
||||||
|
onRenameSessionTitle?: (title: string) => void;
|
||||||
|
onNewConversation: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentHeader = ({
|
||||||
|
sessionTitle,
|
||||||
|
canRenameSessionTitle = false,
|
||||||
|
isHydrating = false,
|
||||||
|
isStreaming,
|
||||||
|
isHistoryOpen,
|
||||||
|
onHistoryToggle,
|
||||||
|
onRenameSessionTitle,
|
||||||
|
onNewConversation,
|
||||||
|
onClose,
|
||||||
|
}: AgentHeaderProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const displayTitle = sessionTitle?.trim() || "新对话";
|
||||||
|
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||||
|
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isEditingTitle) {
|
||||||
|
setDraftTitle(sessionTitle?.trim() || "");
|
||||||
|
}
|
||||||
|
}, [isEditingTitle, sessionTitle]);
|
||||||
|
|
||||||
|
const handleStartEditing = () => {
|
||||||
|
if (!canRenameSessionTitle || isHydrating || isStreaming) return;
|
||||||
|
setDraftTitle(sessionTitle?.trim() || "");
|
||||||
|
setIsEditingTitle(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEditing = () => {
|
||||||
|
setDraftTitle(sessionTitle?.trim() || "");
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmEditing = () => {
|
||||||
|
const normalizedTitle = draftTitle.trim();
|
||||||
|
if (!normalizedTitle) return;
|
||||||
|
onRenameSessionTitle?.(normalizedTitle);
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 2.5,
|
||||||
|
zIndex: 10,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
||||||
|
background: `linear-gradient(to bottom, ${alpha("#fff", 0.4)}, ${alpha("#fff", 0.1)})`,
|
||||||
|
boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2} sx={{ minWidth: 0, flex: 1, mr: 2 }}>
|
||||||
|
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex", flexShrink: 0 }}>
|
||||||
|
<Box sx={{ position: "relative" }}>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
background: alpha("#ffffff", 0.9),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
border: `2px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
p: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/ai-agent.svg"
|
||||||
|
alt="TJWater Agent"
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
bgcolor: isStreaming ? "#ff9800" : "#00e676",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "2.5px solid #fff",
|
||||||
|
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
|
||||||
|
animation: isStreaming ? "pulse 1.5s infinite" : "none",
|
||||||
|
"@keyframes pulse": {
|
||||||
|
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
||||||
|
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
|
||||||
|
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</motion.div>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
{isEditingTitle ? (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ width: "100%" }}>
|
||||||
|
<TextField
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
placeholder="请输入对话标题"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleConfirmEditing();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleCancelEditing();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
padding: "6px 8px",
|
||||||
|
bgcolor: "transparent",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
"&.Mui-focused": {
|
||||||
|
bgcolor: alpha("#fff", 0.6),
|
||||||
|
boxShadow: `0 2px 10px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
"& fieldset": {
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
"&:hover fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.2),
|
||||||
|
},
|
||||||
|
"&.Mui-focused fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||||
|
borderWidth: "1px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
padding: 0,
|
||||||
|
height: "auto",
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
lineHeight: "1.2",
|
||||||
|
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认"
|
||||||
|
onClick={handleConfirmEditing}
|
||||||
|
disabled={!draftTitle.trim()}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "success.main",
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.1),
|
||||||
|
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消"
|
||||||
|
onClick={handleCancelEditing}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontWeight={800}
|
||||||
|
sx={{
|
||||||
|
background: `linear-gradient(90deg, #01579b, #00838f)`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
px: "8px",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</Typography>
|
||||||
|
{canRenameSessionTitle ? (
|
||||||
|
<Tooltip title="修改对话标题">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="修改对话标题"
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
disabled={isHydrating || isStreaming}
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
color: "primary.main",
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditRounded sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.25} alignItems="center" sx={{ flexShrink: 0 }}>
|
||||||
|
<Tooltip title="新建对话">
|
||||||
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onNewConversation}
|
||||||
|
aria-label="新建对话"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "text.primary",
|
||||||
|
bgcolor: alpha("#fff", 0.54),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.4)}`,
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: "#00acc1",
|
||||||
|
borderColor: alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditNoteRounded sx={{ fontSize: 22 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={isHistoryOpen ? "收起历史会话" : "打开历史会话"}>
|
||||||
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onHistoryToggle}
|
||||||
|
aria-label={isHistoryOpen ? "收起历史会话" : "打开历史会话"}
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: isHistoryOpen ? "#00acc1" : "text.primary",
|
||||||
|
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.12) : alpha("#fff", 0.54),
|
||||||
|
border: `1px solid ${isHistoryOpen ? alpha("#00acc1", 0.2) : alpha("#fff", 0.4)}`,
|
||||||
|
boxShadow: `0 2px 8px ${isHistoryOpen ? alpha("#00acc1", 0.05) : alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.16) : "#fff",
|
||||||
|
borderColor: isHistoryOpen ? alpha("#00acc1", 0.3) : alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${isHistoryOpen ? alpha("#00acc1", 0.1) : alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HistoryRounded sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="关闭 Agent">
|
||||||
|
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭 Agent"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "text.primary",
|
||||||
|
bgcolor: alpha("#fff", 0.54),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.4)}`,
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: "#e53935",
|
||||||
|
borderColor: alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
|
|
||||||
|
const renderWithTheme = (ui: React.ReactElement) =>
|
||||||
|
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
describe("AgentHistoryPanel", () => {
|
||||||
|
it("renames a history session from the list", () => {
|
||||||
|
const onRenameSession = jest.fn();
|
||||||
|
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "旧会话标题",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeSessionId="session-1"
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={onRenameSession}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "修改会话标题" }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("请输入会话标题"), {
|
||||||
|
target: { value: "新的会话标题" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("确认"));
|
||||||
|
|
||||||
|
expect(onRenameSession).toHaveBeenCalledWith("session-1", "新的会话标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders history by the first message time instead of the latest update time", () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={[
|
||||||
|
{
|
||||||
|
id: "session-newer-update",
|
||||||
|
title: "较新的更新",
|
||||||
|
createdAt: new Date("2026-05-18T09:00:00+08:00").getTime(),
|
||||||
|
updatedAt: new Date("2026-05-19T12:00:00+08:00").getTime(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-newer-first-message",
|
||||||
|
title: "较新的首条消息",
|
||||||
|
createdAt: new Date("2026-05-19T08:00:00+08:00").getTime(),
|
||||||
|
updatedAt: new Date("2026-05-19T08:30:00+08:00").getTime(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onNewSession={jest.fn()}
|
||||||
|
onRenameSession={jest.fn()}
|
||||||
|
onSelectSession={jest.fn()}
|
||||||
|
onDeleteSession={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionTitles = screen.getAllByText(/较新的/).map((element) => element.textContent);
|
||||||
|
|
||||||
|
expect(sessionTitles).toEqual(["较新的首条消息", "较新的更新"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CheckRounded from "@mui/icons-material/CheckRounded";
|
||||||
|
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||||
|
import EditRounded from "@mui/icons-material/EditRounded";
|
||||||
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
|
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
|
||||||
|
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
|
||||||
|
import SearchRounded from "@mui/icons-material/SearchRounded";
|
||||||
|
import WarningRounded from "@mui/icons-material/WarningRounded";
|
||||||
|
import type { ChatSessionSummary } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
type AgentHistoryPanelProps = {
|
||||||
|
sessions: ChatSessionSummary[];
|
||||||
|
activeSessionId?: string;
|
||||||
|
isHydrating?: boolean;
|
||||||
|
onNewSession: () => void;
|
||||||
|
onRenameSession: (sessionId: string, title: string) => void;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
onDeleteSession: (sessionId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRelativeDate = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const isSameDay = date.toDateString() === now.toDateString();
|
||||||
|
if (isSameDay) {
|
||||||
|
return date.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString("zh-CN", {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDayStart = (date: Date) =>
|
||||||
|
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
|
||||||
|
const getSessionGroupLabel = (timestamp: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = getDayStart(now);
|
||||||
|
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
|
||||||
|
const lastWeekStart = todayStart - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (timestamp >= todayStart) return "今天";
|
||||||
|
if (timestamp >= yesterdayStart) return "昨天";
|
||||||
|
if (timestamp >= lastWeekStart) return "过去 7 天";
|
||||||
|
return "更早";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentHistoryPanel = ({
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
isHydrating = false,
|
||||||
|
onNewSession,
|
||||||
|
onRenameSession,
|
||||||
|
onSelectSession,
|
||||||
|
onDeleteSession,
|
||||||
|
}: AgentHistoryPanelProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [keyword, setKeyword] = React.useState("");
|
||||||
|
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
||||||
|
const [draftTitle, setDraftTitle] = React.useState("");
|
||||||
|
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const filteredSessions = React.useMemo(() => {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||||
|
if (!normalizedKeyword) return sessions;
|
||||||
|
return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword));
|
||||||
|
}, [keyword, sessions]);
|
||||||
|
|
||||||
|
const sortedFilteredSessions = React.useMemo(
|
||||||
|
() =>
|
||||||
|
[...filteredSessions].sort((left, right) => {
|
||||||
|
const createdAtDiff = right.createdAt - left.createdAt;
|
||||||
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
|
|
||||||
|
const updatedAtDiff = right.updatedAt - left.updatedAt;
|
||||||
|
if (updatedAtDiff !== 0) return updatedAtDiff;
|
||||||
|
|
||||||
|
return right.id.localeCompare(left.id);
|
||||||
|
}),
|
||||||
|
[filteredSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedSessions = React.useMemo(() => {
|
||||||
|
const groups = new Map<string, ChatSessionSummary[]>();
|
||||||
|
|
||||||
|
sortedFilteredSessions.forEach((session) => {
|
||||||
|
const label = getSessionGroupLabel(session.createdAt);
|
||||||
|
const existing = groups.get(label);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(session);
|
||||||
|
} else {
|
||||||
|
groups.set(label, [session]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}, [sortedFilteredSessions]);
|
||||||
|
|
||||||
|
const pendingDeleteSession = filteredSessions.find(
|
||||||
|
(session) => session.id === pendingDeleteSessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartRename = (sessionId: string, title: string) => {
|
||||||
|
setEditingSessionId(sessionId);
|
||||||
|
setDraftTitle(title);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRename = () => {
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setDraftTitle("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRename = (sessionId: string) => {
|
||||||
|
const normalizedTitle = draftTitle.trim();
|
||||||
|
if (!normalizedTitle) return;
|
||||||
|
onRenameSession(sessionId, normalizedTitle);
|
||||||
|
handleCancelRename();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: 268,
|
||||||
|
minWidth: 268,
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
bgcolor: alpha("#ffffff", 0.54),
|
||||||
|
borderRight: `1px solid ${alpha("#fff", 0.75)}`,
|
||||||
|
backdropFilter: "blur(28px)",
|
||||||
|
boxShadow: `inset -1px 0 0 ${alpha("#fff", 0.35)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
|
||||||
|
历史会话
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="新建对话">
|
||||||
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
disabled={isHydrating}
|
||||||
|
onClick={onNewSession}
|
||||||
|
aria-label="新建对话"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "text.primary",
|
||||||
|
bgcolor: alpha("#fff", 0.65),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.5)}`,
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: "#00acc1",
|
||||||
|
borderColor: alpha("#fff", 0.9),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditNoteRounded sx={{ fontSize: 22 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ px: 1.5, pb: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索历史会话"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
disabled={isHydrating}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <SearchRounded sx={{ fontSize: 16, color: "text.secondary", mr: 0.75 }} />,
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#fff", 0.62),
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<Stack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "text.secondary",
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatBubbleOutlineRounded sx={{ fontSize: 24, opacity: 0.7 }} />
|
||||||
|
<Typography variant="body2" fontWeight={700}>
|
||||||
|
暂无历史会话
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
新建对话后会自动出现在这里
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : filteredSessions.length === 0 ? (
|
||||||
|
<Stack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "text.secondary",
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchRounded sx={{ fontSize: 24, opacity: 0.7 }} />
|
||||||
|
<Typography variant="body2" fontWeight={700}>
|
||||||
|
未找到匹配会话
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption">
|
||||||
|
试试其他关键词
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{groupedSessions.map(([groupLabel, groupSessions]) => (
|
||||||
|
<Box key={groupLabel}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
fontWeight={800}
|
||||||
|
sx={{ px: 0.5, mb: 0.75, display: "block", letterSpacing: 0.3 }}
|
||||||
|
>
|
||||||
|
{groupLabel}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{groupSessions.map((session) => {
|
||||||
|
const isActive = session.id === activeSessionId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={session.id}
|
||||||
|
elevation={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (editingSessionId === session.id) return;
|
||||||
|
onSelectSession(session.id);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: isHydrating ? "default" : "pointer",
|
||||||
|
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56),
|
||||||
|
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`,
|
||||||
|
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`,
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
pointerEvents: isHydrating ? "none" : "auto",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86),
|
||||||
|
borderColor: alpha("#00acc1", 0.2),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{editingSessionId === session.id ? (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
|
<TextField
|
||||||
|
value={draftTitle}
|
||||||
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
placeholder="请输入会话标题"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleConfirmRename(session.id);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCancelRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
height: 32,
|
||||||
|
bgcolor: alpha("#fff", 0.75),
|
||||||
|
borderRadius: 1.5,
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
"& fieldset": {
|
||||||
|
borderColor: alpha("#000", 0.08),
|
||||||
|
},
|
||||||
|
"&:hover fieldset": {
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.4),
|
||||||
|
},
|
||||||
|
"&.Mui-focused fieldset": {
|
||||||
|
borderColor: theme.palette.primary.main,
|
||||||
|
borderWidth: "1.5px",
|
||||||
|
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleConfirmRename(session.id);
|
||||||
|
}}
|
||||||
|
disabled={!draftTitle.trim()}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "success.main",
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.1),
|
||||||
|
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCancelRename();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
) : pendingDeleteSessionId === session.id ? (
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: alpha("#ef5350", 0.15),
|
||||||
|
color: "#ef5350",
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WarningRounded sx={{ fontSize: 13 }} />
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={800}
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认删除此会话?
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={isActive ? 800 : 700}
|
||||||
|
color="text.primary"
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
|
||||||
|
{formatRelativeDate(session.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id) && (
|
||||||
|
<Stack direction="row" spacing={0.25}>
|
||||||
|
<Tooltip title="修改会话标题">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="修改会话标题"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleStartRename(session.id, session.title);
|
||||||
|
}}
|
||||||
|
disabled={isHydrating || editingSessionId === session.id}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
color: "primary.main",
|
||||||
|
bgcolor: alpha("#00acc1", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除会话">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="删除会话"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPendingDeleteSessionId(session.id);
|
||||||
|
}}
|
||||||
|
disabled={isHydrating}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": {
|
||||||
|
color: "error.main",
|
||||||
|
bgcolor: alpha("#ef5350", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingDeleteSessionId === session.id && (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="确认删除"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDeleteSession(session.id);
|
||||||
|
setPendingDeleteSessionId(null);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "error.main",
|
||||||
|
bgcolor: alpha("#ef5350", 0.1),
|
||||||
|
"&:hover": { bgcolor: alpha("#ef5350", 0.2) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消删除"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPendingDeleteSessionId(null);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
|
import type { ChatProgress } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
describe("AgentProgressTimeline", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the running step and keeps the timeline expanded while running", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const progress: ChatProgress[] = [
|
||||||
|
{
|
||||||
|
id: "start",
|
||||||
|
phase: "start",
|
||||||
|
status: "running",
|
||||||
|
title: "收到请求",
|
||||||
|
startedAt: now - 5000,
|
||||||
|
elapsedMs: 5000,
|
||||||
|
elapsedSnapshotAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tool",
|
||||||
|
phase: "tool",
|
||||||
|
status: "running",
|
||||||
|
title: "正在调用 tjwater_cli",
|
||||||
|
detail: "analysis bottlenecks",
|
||||||
|
startedAt: now - 1200,
|
||||||
|
elapsedMs: 1200,
|
||||||
|
elapsedSnapshotAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AgentProgressTimeline progress={progress} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("analysis bottlenecks")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1.2s")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes completed steps and lets users expand details", async () => {
|
||||||
|
const progress: ChatProgress[] = [
|
||||||
|
{
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "completed",
|
||||||
|
title: "收到请求",
|
||||||
|
startedAt: Date.now() - 8000,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 8000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "done",
|
||||||
|
phase: "complete",
|
||||||
|
status: "completed",
|
||||||
|
title: "分析完成",
|
||||||
|
startedAt: Date.now() - 1000,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 1000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AgentProgressTimeline progress={progress} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/耗时 8.0s/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("分析完成")).not.toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/Agent 过程:/));
|
||||||
|
|
||||||
|
expect(screen.getByText("分析完成")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats stale running steps as finished after a complete event", () => {
|
||||||
|
const progress: ChatProgress[] = [
|
||||||
|
{
|
||||||
|
id: "tool",
|
||||||
|
phase: "tool",
|
||||||
|
status: "completed",
|
||||||
|
title: "正在调用 tjwater_cli",
|
||||||
|
startedAt: Date.now() - 4000,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "done",
|
||||||
|
phase: "complete",
|
||||||
|
status: "completed",
|
||||||
|
title: "分析完成",
|
||||||
|
startedAt: Date.now() - 500,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 500,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AgentProgressTimeline progress={progress} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("4.0s")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Collapse,
|
||||||
|
LinearProgress,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded";
|
||||||
|
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
|
||||||
|
import TaskAltRounded from "@mui/icons-material/TaskAltRounded";
|
||||||
|
import PsychologyRounded from "@mui/icons-material/PsychologyRounded";
|
||||||
|
import SyncRounded from "@mui/icons-material/SyncRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
|
||||||
|
import type { ChatProgress } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const formatDuration = (durationMs: number) => {
|
||||||
|
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
||||||
|
return "0s";
|
||||||
|
}
|
||||||
|
if (durationMs < 10_000) {
|
||||||
|
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.round(durationMs / 1000);
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainMinutes = minutes % 60;
|
||||||
|
return `${hours}h ${remainMinutes.toString().padStart(2, "0")}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressElapsedMs = (item: ChatProgress, nowMs: number) => {
|
||||||
|
if (item.durationMs !== undefined) {
|
||||||
|
return item.durationMs;
|
||||||
|
}
|
||||||
|
if (item.status === "running") {
|
||||||
|
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
|
||||||
|
return Math.max(0, item.elapsedMs + (nowMs - item.elapsedSnapshotAt));
|
||||||
|
}
|
||||||
|
if (item.startedAt !== undefined) {
|
||||||
|
return Math.max(0, nowMs - item.startedAt);
|
||||||
|
}
|
||||||
|
return item.elapsedMs;
|
||||||
|
}
|
||||||
|
if (item.startedAt !== undefined && item.endedAt !== undefined) {
|
||||||
|
return Math.max(0, item.endedAt - item.startedAt);
|
||||||
|
}
|
||||||
|
return item.elapsedMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
|
||||||
|
const sx = { fontSize: 16 };
|
||||||
|
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
|
||||||
|
if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />;
|
||||||
|
if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "#00acc1" }} />;
|
||||||
|
if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />;
|
||||||
|
if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />;
|
||||||
|
if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />;
|
||||||
|
if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "#00acc1" }} />;
|
||||||
|
return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatToolTitle = (item: ChatProgress) => {
|
||||||
|
const text = `${item.title} ${item.detail ?? ""}`;
|
||||||
|
if (text.includes("tjwater_cli")) return "查询后端数据";
|
||||||
|
if (text.includes("show_chart")) return "生成图表";
|
||||||
|
if (text.includes("locate_features")) return "地图定位";
|
||||||
|
if (text.includes("view_history")) return "打开历史曲线";
|
||||||
|
if (text.includes("view_scada")) return "打开 SCADA 面板";
|
||||||
|
if (text.includes("render_junctions")) return "渲染节点";
|
||||||
|
return item.title;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentProgressTimelineProps = {
|
||||||
|
progress: ChatProgress[];
|
||||||
|
isAborted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
|
||||||
|
const isOverallComplete = progress.some(
|
||||||
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 修正状态判断:如果外部标记为中断,或者没有完成标记
|
||||||
|
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
|
||||||
|
const hasError = isAborted || progress.some((item) => item.status === "error");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
}, 500);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [hasRunning]);
|
||||||
|
|
||||||
|
// 展开状态逻辑:默认折叠,保持界面整洁
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (isAborted) return `已中断 (进行到第 ${progress.length} 步)`;
|
||||||
|
if (isOverallComplete) {
|
||||||
|
return hasError ? `已完成 (含 ${progress.length} 步探索)` : `已完成 (${progress.length} 步)`;
|
||||||
|
}
|
||||||
|
const runningItem = [...progress].reverse().find((item) => item.status === "running");
|
||||||
|
if (runningItem) return `${runningItem.title}...`;
|
||||||
|
if (hasError) return "过程异常,尝试恢复中...";
|
||||||
|
return `已执行 ${progress.length} 步`;
|
||||||
|
}, [isOverallComplete, hasError, progress, isAborted]);
|
||||||
|
|
||||||
|
const totalDurationLabel = useMemo(() => {
|
||||||
|
const requestProgress = progress.find((item) => item.id === "request-received");
|
||||||
|
const requestElapsed =
|
||||||
|
requestProgress ? getProgressElapsedMs(requestProgress, nowMs) : undefined;
|
||||||
|
if (requestElapsed !== undefined) {
|
||||||
|
return formatDuration(requestElapsed);
|
||||||
|
}
|
||||||
|
const startedAtValues = progress
|
||||||
|
.map((item) => item.startedAt)
|
||||||
|
.filter((value): value is number => value !== undefined);
|
||||||
|
if (startedAtValues.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const minStartedAt = Math.min(...startedAtValues);
|
||||||
|
const endedAtValues = progress
|
||||||
|
.map((item) => item.endedAt)
|
||||||
|
.filter((value): value is number => value !== undefined);
|
||||||
|
const endAnchor = isOverallComplete
|
||||||
|
? endedAtValues.length > 0
|
||||||
|
? Math.max(...endedAtValues)
|
||||||
|
: nowMs
|
||||||
|
: nowMs;
|
||||||
|
return formatDuration(Math.max(0, endAnchor - minStartedAt));
|
||||||
|
}, [isOverallComplete, nowMs, progress]);
|
||||||
|
|
||||||
|
// 根据整体状态决定顶部卡片的颜色主题
|
||||||
|
const statusColor = isOverallComplete
|
||||||
|
? "#4caf50" // Success Green
|
||||||
|
: isAborted || (hasError && !hasRunning)
|
||||||
|
? theme.palette.error.main // Error Red
|
||||||
|
: "#00acc1"; // Primary Cyan
|
||||||
|
|
||||||
|
// 默认折叠:只显示最新的三条
|
||||||
|
const visibleCount = 3;
|
||||||
|
const isCollapsible = progress.length > visibleCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha(statusColor, 0.04),
|
||||||
|
border: `1px solid ${alpha(statusColor, 0.15)}`,
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha(statusColor, 0.06),
|
||||||
|
borderColor: alpha(statusColor, 0.25),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1.5}
|
||||||
|
alignItems="center"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOverallComplete ? (
|
||||||
|
<TaskAltRounded sx={{ fontSize: 18, color: statusColor }} />
|
||||||
|
) : hasRunning ? (
|
||||||
|
<AutoAwesome sx={{ fontSize: 18, color: statusColor, animation: "spin 2s linear infinite", "@keyframes spin": { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" } } }} />
|
||||||
|
) : hasError ? (
|
||||||
|
<ErrorOutlineRounded sx={{ fontSize: 18, color: statusColor }} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesome sx={{ fontSize: 18, color: statusColor }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
|
||||||
|
Agent 过程: {summary}
|
||||||
|
{totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<KeyboardArrowDownRounded
|
||||||
|
sx={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: "text.secondary",
|
||||||
|
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{hasRunning && !expanded ? (
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
height: 2,
|
||||||
|
bgcolor: "transparent",
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapse in={expanded || hasRunning} timeout="auto" unmountOnExit={false}>
|
||||||
|
<Box>
|
||||||
|
{hasRunning ? (
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
height: 1,
|
||||||
|
bgcolor: alpha(statusColor, 0.1),
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 1, bgcolor: alpha(statusColor, 0.1) }} />
|
||||||
|
)}
|
||||||
|
<Stack spacing={0} sx={{ px: 2, py: 1.5 }}>
|
||||||
|
{progress.map((item, index) => {
|
||||||
|
const isLast = index === progress.length - 1;
|
||||||
|
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
|
||||||
|
const stepElapsedMs = getProgressElapsedMs(item, nowMs);
|
||||||
|
|
||||||
|
const itemColor = isAborted && isLast
|
||||||
|
? theme.palette.error.main
|
||||||
|
: item.status === "error"
|
||||||
|
? theme.palette.error.main
|
||||||
|
: item.status === "completed"
|
||||||
|
? "#4caf50"
|
||||||
|
: "#00acc1";
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Stack key={item.id} direction="row" spacing={1.5} alignItems="stretch">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: 20,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
pt: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isLast ? (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 22,
|
||||||
|
bottom: -6,
|
||||||
|
left: "50%",
|
||||||
|
width: 2,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(itemColor, item.status === "completed" ? 0.2 : 0.4),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: alpha(theme.palette.background.paper, 0.9),
|
||||||
|
boxShadow: `0 0 0 2px ${alpha(itemColor, 0.1)}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phaseIcon(
|
||||||
|
item.phase,
|
||||||
|
isAborted && isLast ? "error" :
|
||||||
|
isOverallComplete && item.status === "running"
|
||||||
|
? "completed"
|
||||||
|
: item.status,
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||||
|
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
|
||||||
|
{item.phase === "tool" ? formatToolTitle(item) : item.title}
|
||||||
|
</Typography>
|
||||||
|
{stepElapsedMs !== undefined ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: "0.68rem", fontFamily: "var(--font-mono, monospace)" }}
|
||||||
|
>
|
||||||
|
{formatDuration(stepElapsedMs)}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{item.detail && (
|
||||||
|
<Collapse in={expanded || isLast} timeout="auto">
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: alpha(itemColor, 0.05),
|
||||||
|
border: `1px solid ${alpha(itemColor, 0.1)}`,
|
||||||
|
color: "text.secondary",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
fontFamily: "var(--font-mono, monospace)",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.detail}
|
||||||
|
</Typography>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isHiddenWhenCollapsed) {
|
||||||
|
return (
|
||||||
|
<Collapse key={item.id} in={expanded} timeout="auto" unmountOnExit={false}>
|
||||||
|
{content}
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentProgressTimeline = React.memo(
|
||||||
|
AgentProgressTimelineInner,
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.progress === nextProps.progress &&
|
||||||
|
prevProps.isAborted === nextProps.isAborted,
|
||||||
|
);
|
||||||
|
|
||||||
|
AgentProgressTimeline.displayName = "AgentProgressTimeline";
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||||
|
import RefreshRounded from "@mui/icons-material/RefreshRounded";
|
||||||
|
import EditRounded from "@mui/icons-material/EditRounded";
|
||||||
|
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||||
|
import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
|
||||||
|
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
|
||||||
|
import {
|
||||||
|
parseAssistantMessageSections,
|
||||||
|
parseContentWithToolCalls,
|
||||||
|
type ContentSegment,
|
||||||
|
} from "./chatMessageSections";
|
||||||
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||||
|
import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
|
||||||
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||||
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
|
import type { ChatChartSeries } from "./ChatInlineChart";
|
||||||
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
|
import { AgentArtifactPanel } from "./AgentArtifactPanel";
|
||||||
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||||
|
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||||
|
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||||
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
|
import SendRounded from "@mui/icons-material/SendRounded";
|
||||||
|
|
||||||
|
type AgentTurnProps = {
|
||||||
|
message: Message;
|
||||||
|
branchState?: BranchState;
|
||||||
|
messageSpeechState: SpeechState;
|
||||||
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||||
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkdownBlock = ({ children }: { children: string }) => (
|
||||||
|
<div className={markdownStyles.markdown}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AgentTurn = React.memo(
|
||||||
|
({
|
||||||
|
message,
|
||||||
|
branchState,
|
||||||
|
messageSpeechState,
|
||||||
|
onSpeak,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onRegenerate,
|
||||||
|
onEditResubmit,
|
||||||
|
onCycleBranch,
|
||||||
|
}: AgentTurnProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isErrorMessage = Boolean(message.isError);
|
||||||
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
|
const [isEditing, setIsEditing] = React.useState(false);
|
||||||
|
const [editDraft, setEditDraft] = React.useState(message.content);
|
||||||
|
const rootMessageId = message.branchRootId ?? message.id;
|
||||||
|
|
||||||
|
const parsedAssistantSections = useMemo(
|
||||||
|
() =>
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseAssistantMessageSections(message.content)
|
||||||
|
: null,
|
||||||
|
[isErrorMessage, isUser, message.content],
|
||||||
|
);
|
||||||
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
|
const contentSegments: ContentSegment[] = useMemo(
|
||||||
|
() =>
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseContentWithToolCalls(answerContent).segments
|
||||||
|
: [{ type: "text", content: answerContent }],
|
||||||
|
[answerContent, isErrorMessage, isUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 350, damping: 25 }}
|
||||||
|
style={{ alignSelf: "flex-end", maxWidth: "86%", position: "relative" }}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<Paper
|
||||||
|
elevation={12}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.75),
|
||||||
|
backdropFilter: "blur(40px)",
|
||||||
|
border: `1px solid ${alpha("#ffffff", 0.9)}`,
|
||||||
|
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||||
|
minWidth: { xs: 260, sm: 320, md: 400 },
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box component="textarea"
|
||||||
|
autoFocus
|
||||||
|
value={editDraft}
|
||||||
|
onChange={(e) => setEditDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editDraft.trim() !== message.content) {
|
||||||
|
onEditResubmit(message.id, editDraft);
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setEditDraft(message.content);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 60,
|
||||||
|
bgcolor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
resize: "none",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: "1rem",
|
||||||
|
color: "text.primary",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="取消"
|
||||||
|
onClick={() => { setEditDraft(message.content); setIsEditing(false); }}
|
||||||
|
sx={{
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
color: "text.secondary",
|
||||||
|
width: 34, height: 34,
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.1) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="发送修改"
|
||||||
|
disabled={editDraft.trim() === "" || editDraft.trim() === message.content}
|
||||||
|
onClick={() => {
|
||||||
|
onEditResubmit(message.id, editDraft);
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1),
|
||||||
|
color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled",
|
||||||
|
width: 34, height: 34,
|
||||||
|
boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none",
|
||||||
|
"&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SendRounded fontSize="small" sx={{ ml: 0.2 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
borderBottomRightRadius: 2,
|
||||||
|
color: "#fff",
|
||||||
|
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
|
||||||
|
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
"--chat-md-text": alpha("#fff", 0.96),
|
||||||
|
"--chat-md-heading": "#fff",
|
||||||
|
"--chat-md-link": "#e0f7fa",
|
||||||
|
"--chat-md-link-hover": "#fff",
|
||||||
|
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
|
||||||
|
"--chat-md-inline-code-border": alpha("#fff", 0.1),
|
||||||
|
"--chat-md-inline-code-text": "#fff",
|
||||||
|
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
|
||||||
|
"--chat-md-pre-border": alpha("#fff", 0.1),
|
||||||
|
"--chat-md-pre-text": "#F8FAFC",
|
||||||
|
"--chat-md-quote-border": alpha("#fff", 0.4),
|
||||||
|
"--chat-md-quote-bg": alpha("#fff", 0.05),
|
||||||
|
"--chat-md-quote-text": alpha("#fff", 0.8),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MarkdownBlock>{message.content}</MarkdownBlock>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isHovered && !isEditing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setIsEditing(true); setEditDraft(message.content); }}
|
||||||
|
aria-label="编辑提问"
|
||||||
|
sx={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
bgcolor: alpha("#fff", 0.9),
|
||||||
|
color: "#00acc1",
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`,
|
||||||
|
"&:hover": { bgcolor: "#fff", color: "#00838f" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditRounded sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{branchState && branchState.total > 1 ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
sx={{ mt: 0.5, mr: 0.5 }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
px: 0.5,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#000", 0.04),
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
border: `1px solid ${alpha("#000", 0.08)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="上一分支"
|
||||||
|
onClick={() => onCycleBranch(rootMessageId, -1)}
|
||||||
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||||
|
>
|
||||||
|
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
||||||
|
{branchState.activeIndex + 1} / {branchState.total}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="下一分支"
|
||||||
|
onClick={() => onCycleBranch(rootMessageId, 1)}
|
||||||
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||||
|
>
|
||||||
|
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 14 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 320, damping: 26 }}
|
||||||
|
style={{ width: "100%", position: "relative" }}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
background: alpha("#ffffff", 0.9),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#00acc1", 0.25)}`,
|
||||||
|
border: `1.5px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
color: "#00acc1",
|
||||||
|
mt: 0.25,
|
||||||
|
p: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/ai-agent.svg"
|
||||||
|
alt="TJWater Agent"
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
style={{ objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
bgcolor: alpha("#ffffff", 0.65),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
boxShadow: `0 10px 30px -10px ${alpha(theme.palette.common.black, 0.08)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
position: "relative",
|
||||||
|
"--chat-md-text": "text.primary",
|
||||||
|
"--chat-md-heading": "text.primary",
|
||||||
|
"--chat-md-link": "#00838f",
|
||||||
|
"--chat-md-link-hover": "#00acc1",
|
||||||
|
"--chat-md-inline-code-bg": alpha("#00acc1", 0.08),
|
||||||
|
"--chat-md-inline-code-border": alpha("#00acc1", 0.15),
|
||||||
|
"--chat-md-inline-code-text": "#006064",
|
||||||
|
"--chat-md-pre-bg": "#1e293b",
|
||||||
|
"--chat-md-pre-border": "#475569",
|
||||||
|
"--chat-md-pre-text": "#f1f5f9",
|
||||||
|
"--chat-md-quote-border": "#00acc1",
|
||||||
|
"--chat-md-quote-bg": alpha("#00acc1", 0.04),
|
||||||
|
"--chat-md-quote-text": "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{message.progress?.length ? (
|
||||||
|
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.4),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.2}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
|
分析结果
|
||||||
|
</Typography>
|
||||||
|
{contentSegments.map((segment, segIdx) => {
|
||||||
|
if (segment.type === "text") {
|
||||||
|
const text = segment.content.trim();
|
||||||
|
if (!text && contentSegments.length > 1) return null;
|
||||||
|
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call") {
|
||||||
|
if (
|
||||||
|
segment.toolCall.tool === "chart" ||
|
||||||
|
segment.toolCall.tool === "show_chart"
|
||||||
|
) {
|
||||||
|
const p = segment.toolCall.params;
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
title={(p.title as string) ?? undefined}
|
||||||
|
chart_type={
|
||||||
|
(p.chart_type as "line" | "bar" | "pie") ?? "line"
|
||||||
|
}
|
||||||
|
x_data={(p.x_data as string[]) ?? []}
|
||||||
|
series={(p.series as ChatChartSeries[]) ?? []}
|
||||||
|
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatToolCallBlock
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
toolCall={segment.toolCall}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call_pending") {
|
||||||
|
return (
|
||||||
|
<Typography key="tool-pending" variant="caption" color="text.secondary">
|
||||||
|
正在准备工具调用...
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isHovered && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={4}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 0.5,
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: "16px",
|
||||||
|
bgcolor: alpha("#fff", 0.8),
|
||||||
|
backdropFilter: "blur(16px)",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="复制">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="复制"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(message.content);
|
||||||
|
// Could add a toast here
|
||||||
|
}}
|
||||||
|
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||||
|
>
|
||||||
|
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重新生成">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="重新生成"
|
||||||
|
onClick={() => {
|
||||||
|
onRegenerate();
|
||||||
|
}}
|
||||||
|
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||||
|
>
|
||||||
|
<RefreshRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
|
||||||
|
{!isErrorMessage && isTtsSupported ? (
|
||||||
|
<>
|
||||||
|
{messageSpeechState === "idle" ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
||||||
|
aria-label="朗读消息"
|
||||||
|
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||||
|
>
|
||||||
|
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
{messageSpeechState === "playing" ? (
|
||||||
|
<>
|
||||||
|
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||||
|
<PauseRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{messageSpeechState === "paused" ? (
|
||||||
|
<>
|
||||||
|
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||||
|
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||||
|
<StopRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{branchState && branchState.total > 1 ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-start"
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
px: 0.5,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#000", 0.04),
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
border: `1px solid ${alpha("#000", 0.08)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="上一分支"
|
||||||
|
onClick={() => onCycleBranch(rootMessageId, -1)}
|
||||||
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||||
|
>
|
||||||
|
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
||||||
|
{branchState.activeIndex + 1} / {branchState.total}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="下一分支"
|
||||||
|
onClick={() => onCycleBranch(rootMessageId, 1)}
|
||||||
|
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||||
|
>
|
||||||
|
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AgentTurn.displayName = "AgentTurn";
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
const renderCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
jest.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt ?? ""} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("framer-motion", () => ({
|
||||||
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
motion: {
|
||||||
|
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./GlobalChatbox.parts", () => ({
|
||||||
|
TypingIndicator: () => <div>typing</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("./AgentTurn", () => ({
|
||||||
|
AgentTurn: ({ message }: { message: Message }) => {
|
||||||
|
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
||||||
|
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AgentWorkspace", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
branchGroups: [],
|
||||||
|
branchTransition: null,
|
||||||
|
bottomRef: { current: null },
|
||||||
|
speakingMessageId: null,
|
||||||
|
speechState: "idle" as const,
|
||||||
|
onSpeak: jest.fn(),
|
||||||
|
onPauseSpeech: jest.fn(),
|
||||||
|
onResumeSpeech: jest.fn(),
|
||||||
|
onStopSpeech: jest.fn(),
|
||||||
|
isTtsSupported: false,
|
||||||
|
onRegenerate: jest.fn(),
|
||||||
|
onEditResubmit: jest.fn(),
|
||||||
|
onCycleBranch: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
renderCounts.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: "user-1",
|
||||||
|
role: "user",
|
||||||
|
content: "question",
|
||||||
|
};
|
||||||
|
const assistantHistoryMessage: Message = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "stable answer",
|
||||||
|
};
|
||||||
|
const streamingMessage: Message = {
|
||||||
|
id: "assistant-2",
|
||||||
|
role: "assistant",
|
||||||
|
content: "partial",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantHistoryMessage, streamingMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedStreamingMessage: Message = {
|
||||||
|
...streamingMessage,
|
||||||
|
content: "partial with more tokens",
|
||||||
|
};
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renderCounts.get("user-1")).toBe(1);
|
||||||
|
expect(renderCounts.get("assistant-1")).toBe(1);
|
||||||
|
expect(renderCounts.get("assistant-2")).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
|
||||||
|
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
||||||
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
|
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||||||
|
import MapRounded from "@mui/icons-material/MapRounded";
|
||||||
|
|
||||||
|
import { AgentTurn } from "./AgentTurn";
|
||||||
|
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||||||
|
import type {
|
||||||
|
BranchGroup,
|
||||||
|
BranchState,
|
||||||
|
BranchTransition,
|
||||||
|
Message,
|
||||||
|
SpeechState,
|
||||||
|
} from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
type AgentWorkspaceProps = {
|
||||||
|
messages: Message[];
|
||||||
|
branchGroups: BranchGroup[];
|
||||||
|
branchTransition: BranchTransition | null;
|
||||||
|
isStreaming: boolean;
|
||||||
|
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
speakingMessageId: string | null;
|
||||||
|
speechState: SpeechState;
|
||||||
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
|
onPauseSpeech: () => void;
|
||||||
|
onResumeSpeech: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||||
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TurnListProps = {
|
||||||
|
messages: Message[];
|
||||||
|
branchGroups: BranchGroup[];
|
||||||
|
speakingMessageId: string | null;
|
||||||
|
speechState: SpeechState;
|
||||||
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
|
onPauseSpeech: () => void;
|
||||||
|
onResumeSpeech: () => void;
|
||||||
|
onStopSpeech: () => void;
|
||||||
|
isTtsSupported: boolean;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||||
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((message, index) => message === right[index]);
|
||||||
|
|
||||||
|
const TurnListInner = ({
|
||||||
|
messages,
|
||||||
|
branchGroups,
|
||||||
|
speakingMessageId,
|
||||||
|
speechState,
|
||||||
|
onSpeak,
|
||||||
|
onPauseSpeech,
|
||||||
|
onResumeSpeech,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onRegenerate,
|
||||||
|
onEditResubmit,
|
||||||
|
onCycleBranch,
|
||||||
|
}: TurnListProps) => {
|
||||||
|
const branchStateByRootId = React.useMemo(() => {
|
||||||
|
const next = new Map<string, BranchState>();
|
||||||
|
branchGroups.forEach((group) => {
|
||||||
|
if (group.branches.length > 1) {
|
||||||
|
next.set(group.rootMessageId, {
|
||||||
|
activeIndex: group.activeIndex,
|
||||||
|
total: group.branches.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}, [branchGroups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messages.map((message) => {
|
||||||
|
const rootMessageId = message.branchRootId ?? message.id;
|
||||||
|
return (
|
||||||
|
<AgentTurn
|
||||||
|
key={rootMessageId}
|
||||||
|
message={message}
|
||||||
|
branchState={branchStateByRootId.get(rootMessageId)}
|
||||||
|
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPause={onPauseSpeech}
|
||||||
|
onResume={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TurnList = React.memo(
|
||||||
|
TurnListInner,
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||||
|
prevProps.branchGroups === nextProps.branchGroups &&
|
||||||
|
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||||
|
prevProps.speechState === nextProps.speechState &&
|
||||||
|
prevProps.onSpeak === nextProps.onSpeak &&
|
||||||
|
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
|
||||||
|
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||||||
|
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||||||
|
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||||
|
prevProps.onRegenerate === nextProps.onRegenerate &&
|
||||||
|
prevProps.onEditResubmit === nextProps.onEditResubmit &&
|
||||||
|
prevProps.onCycleBranch === nextProps.onCycleBranch,
|
||||||
|
);
|
||||||
|
|
||||||
|
TurnList.displayName = "TurnList";
|
||||||
|
|
||||||
|
const EmptyState = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const capabilities = [
|
||||||
|
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
|
||||||
|
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
|
||||||
|
{ icon: <TroubleshootRounded sx={{ fontSize: 20, color: "#43a047" }} />, label: "调度与改造建议" },
|
||||||
|
{ icon: <MapRounded sx={{ fontSize: 20, color: "#8e24aa" }} />, label: "GIS 地图联动" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||||
|
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#ffffff", 0.4),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.8)}`,
|
||||||
|
boxShadow: `0 16px 40px ${alpha("#000", 0.05)}`,
|
||||||
|
textAlign: "center",
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -100,
|
||||||
|
right: -100,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
background: "radial-gradient(circle, rgba(0, 172, 193, 0.15) 0%, rgba(255,255,255,0) 70%)",
|
||||||
|
}} />
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
y: [-6, 4, -6],
|
||||||
|
scale: [1, 1.04, 1],
|
||||||
|
rotate: [-3, 3, -3],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "radial-gradient(circle, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.45) 58%, rgba(255,255,255,0) 100%)",
|
||||||
|
boxShadow: "0 10px 28px rgba(0, 131, 143, 0.12)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/ai-agent.svg"
|
||||||
|
alt="TJWater Agent"
|
||||||
|
width={54}
|
||||||
|
height={54}
|
||||||
|
style={{
|
||||||
|
objectFit: "contain",
|
||||||
|
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
|
||||||
|
我已就绪,请描述任务
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
|
||||||
|
你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={1.5}>
|
||||||
|
{capabilities.map((item) => (
|
||||||
|
<Grid item xs={6} key={item.label}>
|
||||||
|
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.5,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#fff", 0.5),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.03)}`,
|
||||||
|
color: "text.primary",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha("#fff", 0.8),
|
||||||
|
borderColor: alpha("#00acc1", 0.4),
|
||||||
|
boxShadow: `0 6px 16px ${alpha("#00acc1", 0.15)}`,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<Typography variant="caption" fontWeight={700}>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentWorkspace = ({
|
||||||
|
messages,
|
||||||
|
branchGroups,
|
||||||
|
branchTransition,
|
||||||
|
isStreaming,
|
||||||
|
bottomRef,
|
||||||
|
speakingMessageId,
|
||||||
|
speechState,
|
||||||
|
onSpeak,
|
||||||
|
onPauseSpeech,
|
||||||
|
onResumeSpeech,
|
||||||
|
onStopSpeech,
|
||||||
|
isTtsSupported,
|
||||||
|
onRegenerate,
|
||||||
|
onEditResubmit,
|
||||||
|
onCycleBranch,
|
||||||
|
}: AgentWorkspaceProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const latestAssistant = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.role === "assistant");
|
||||||
|
const showTypingIndicator =
|
||||||
|
isStreaming &&
|
||||||
|
(!latestAssistant ||
|
||||||
|
(latestAssistant.content.trim().length === 0 &&
|
||||||
|
!(latestAssistant.artifacts?.length)));
|
||||||
|
const stableMessages = branchTransition
|
||||||
|
? messages.slice(0, branchTransition.parentCount)
|
||||||
|
: messages;
|
||||||
|
const transitionMessages = branchTransition
|
||||||
|
? messages.slice(branchTransition.parentCount)
|
||||||
|
: [];
|
||||||
|
const streamingMessage =
|
||||||
|
!branchTransition && isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
|
? messages.at(-1)
|
||||||
|
: undefined;
|
||||||
|
const historyMessages =
|
||||||
|
streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
px: 2.5,
|
||||||
|
py: 2,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
zIndex: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{messages.length === 0 ? <EmptyState /> : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{messages.length > 0 ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<TurnList
|
||||||
|
messages={historyMessages}
|
||||||
|
branchGroups={branchGroups}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPauseSpeech={onPauseSpeech}
|
||||||
|
onResumeSpeech={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{streamingMessage ? (
|
||||||
|
<TurnList
|
||||||
|
messages={[streamingMessage]}
|
||||||
|
branchGroups={branchGroups}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPauseSpeech={onPauseSpeech}
|
||||||
|
onResumeSpeech={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{branchTransition ? (
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||||
|
>
|
||||||
|
<TurnList
|
||||||
|
messages={transitionMessages}
|
||||||
|
branchGroups={branchGroups}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={onSpeak}
|
||||||
|
onPauseSpeech={onPauseSpeech}
|
||||||
|
onResumeSpeech={onResumeSpeech}
|
||||||
|
onStopSpeech={onStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onEditResubmit={onEditResubmit}
|
||||||
|
onCycleBranch={onCycleBranch}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showTypingIndicator ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300 }}
|
||||||
|
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 1.3,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: alpha("#fff", 0.82),
|
||||||
|
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TypingIndicator />
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div ref={bottomRef} style={{ height: 1 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,638 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useTheme,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
|
||||||
|
import TimelineRounded from "@mui/icons-material/TimelineRounded";
|
||||||
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useChatToolStore,
|
||||||
|
type ChatToolAction,
|
||||||
|
} from "@/store/chatToolStore";
|
||||||
|
import type { ToolCall } from "./chatMessageSections";
|
||||||
|
import {
|
||||||
|
APPLY_LAYER_STYLE_TOOL,
|
||||||
|
describeApplyLayerStyle,
|
||||||
|
parseApplyLayerStylePayload,
|
||||||
|
} from "./toolCallStyleHelpers";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Interactive card rendered inside a chat bubble for tool actions */
|
||||||
|
/* (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 LOCATE_ID_PARAM_KEYS = [
|
||||||
|
"ids",
|
||||||
|
"id",
|
||||||
|
"feature_ids",
|
||||||
|
"feature_id",
|
||||||
|
"node_ids",
|
||||||
|
"node_id",
|
||||||
|
"junction_ids",
|
||||||
|
"junction_id",
|
||||||
|
"pipe_ids",
|
||||||
|
"pipe_id",
|
||||||
|
"valve_ids",
|
||||||
|
"valve_id",
|
||||||
|
"reservoir_ids",
|
||||||
|
"reservoir_id",
|
||||||
|
"pump_ids",
|
||||||
|
"pump_id",
|
||||||
|
"tank_ids",
|
||||||
|
"tank_id",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TOOL_META: Record<string, ToolMeta> = {
|
||||||
|
locate_features: {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
render_junctions: {
|
||||||
|
label: "渲染节点",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "应用渲染",
|
||||||
|
color: "#3b82f6",
|
||||||
|
},
|
||||||
|
[APPLY_LAYER_STYLE_TOOL]: {
|
||||||
|
label: "图层样式",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "应用样式",
|
||||||
|
color: "#14b8a6",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
function normalizeLocateIds(params: Record<string, unknown>): string[] {
|
||||||
|
for (const key of LOCATE_ID_PARAM_KEYS) {
|
||||||
|
const rawValue = params[key];
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
const normalized = rawValue
|
||||||
|
.map((id) => String(id).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof rawValue === "string" || typeof rawValue === "number") {
|
||||||
|
const normalized = String(rawValue)
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolDescription(toolCall: ToolCall): string {
|
||||||
|
const { params } = toolCall;
|
||||||
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
|
const rawFeatureInfos = params.feature_infos;
|
||||||
|
if (Array.isArray(rawFeatureInfos)) {
|
||||||
|
const normalizedFeatureInfos = rawFeatureInfos
|
||||||
|
.map((item) => (Array.isArray(item) ? item : null))
|
||||||
|
.filter((item): item is [unknown, unknown] => Boolean(item))
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.filter(([id]) => id.trim().length > 0);
|
||||||
|
if (normalizedFeatureInfos.length > 0) {
|
||||||
|
return normalizedFeatureInfos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDeviceIds =
|
||||||
|
params.device_ids ??
|
||||||
|
params.deviceId ??
|
||||||
|
params.device_id ??
|
||||||
|
params.id ??
|
||||||
|
params.ids;
|
||||||
|
const deviceIds = Array.isArray(rawDeviceIds)
|
||||||
|
? rawDeviceIds.map((id) => String(id))
|
||||||
|
: typeof rawDeviceIds === "string"
|
||||||
|
? rawDeviceIds
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return deviceIds.map((id) => [id, "scada"]);
|
||||||
|
};
|
||||||
|
const resolveTimeRange = () => ({
|
||||||
|
startTime:
|
||||||
|
(params.start_time as string | undefined) ??
|
||||||
|
(params.startTime as string | undefined) ??
|
||||||
|
(params.from as string | undefined) ??
|
||||||
|
(params.start as string | undefined),
|
||||||
|
endTime:
|
||||||
|
(params.end_time as string | undefined) ??
|
||||||
|
(params.endTime as string | undefined) ??
|
||||||
|
(params.to as string | undefined) ??
|
||||||
|
(params.end as string | undefined),
|
||||||
|
});
|
||||||
|
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 = normalizeLocateIds(params);
|
||||||
|
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) ?? "数据图表";
|
||||||
|
}
|
||||||
|
case "render_junctions": {
|
||||||
|
return (params.render_ref as string | undefined) ?? "渲染引用";
|
||||||
|
}
|
||||||
|
case APPLY_LAYER_STYLE_TOOL: {
|
||||||
|
const payload = parseApplyLayerStylePayload(params);
|
||||||
|
return payload ? describeApplyLayerStyle(payload) : "图层样式";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
||||||
|
const { params } = toolCall;
|
||||||
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
|
const rawFeatureInfos = params.feature_infos;
|
||||||
|
if (Array.isArray(rawFeatureInfos)) {
|
||||||
|
const normalizedFeatureInfos = rawFeatureInfos
|
||||||
|
.map((item) => (Array.isArray(item) ? item : null))
|
||||||
|
.filter((item): item is [unknown, unknown] => Boolean(item))
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.filter(([id]) => id.trim().length > 0);
|
||||||
|
if (normalizedFeatureInfos.length > 0) {
|
||||||
|
return normalizedFeatureInfos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDeviceIds =
|
||||||
|
params.device_ids ??
|
||||||
|
params.deviceId ??
|
||||||
|
params.device_id ??
|
||||||
|
params.id ??
|
||||||
|
params.ids;
|
||||||
|
const deviceIds = Array.isArray(rawDeviceIds)
|
||||||
|
? rawDeviceIds.map((id) => String(id))
|
||||||
|
: typeof rawDeviceIds === "string"
|
||||||
|
? rawDeviceIds
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return deviceIds.map((id) => [id, "scada"]);
|
||||||
|
};
|
||||||
|
const resolveTimeRange = () => ({
|
||||||
|
startTime:
|
||||||
|
(params.start_time as string | undefined) ??
|
||||||
|
(params.startTime as string | undefined) ??
|
||||||
|
(params.from as string | undefined) ??
|
||||||
|
(params.start as string | undefined),
|
||||||
|
endTime:
|
||||||
|
(params.end_time as string | undefined) ??
|
||||||
|
(params.endTime as string | undefined) ??
|
||||||
|
(params.to as string | undefined) ??
|
||||||
|
(params.end as string | undefined),
|
||||||
|
});
|
||||||
|
switch (toolCall.tool) {
|
||||||
|
case "locate_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: normalizeLocateIds(params),
|
||||||
|
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: normalizeLocateIds(params),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
case "render_junctions": {
|
||||||
|
const renderRef =
|
||||||
|
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
|
||||||
|
if (!renderRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "render_junctions",
|
||||||
|
renderRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case APPLY_LAYER_STYLE_TOOL: {
|
||||||
|
const payload = parseApplyLayerStylePayload(params);
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "apply_layer_style",
|
||||||
|
layerId: payload.layerId,
|
||||||
|
resetToDefault: payload.resetToDefault,
|
||||||
|
styleConfig: payload.styleConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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 [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
|
||||||
|
label: toolCall.tool,
|
||||||
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "执行",
|
||||||
|
color: "#00acc1",
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
mb: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${alpha(meta.color, 0.3)}`,
|
||||||
|
bgcolor: alpha(meta.color, 0.05),
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha(meta.color, 0.08),
|
||||||
|
border: `1px solid ${alpha(meta.color, 0.4)}`,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: alpha(meta.color, 0.15),
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: meta.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.icon}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</Typography>
|
||||||
|
{!expanded && description && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 180,
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
• {description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}>
|
||||||
|
{expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
|
||||||
|
<Stack direction="column" spacing={1.5}>
|
||||||
|
{description && (
|
||||||
|
<Box sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#000", 0.03),
|
||||||
|
border: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}>
|
||||||
|
执行参数
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="flex-end">
|
||||||
|
{executed ? (
|
||||||
|
<Chip
|
||||||
|
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
|
||||||
|
label="已执行"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: alpha("#00e676", 0.15),
|
||||||
|
color: "#00c853",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
|
||||||
|
sx={{
|
||||||
|
bgcolor: meta.color,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
borderRadius: 2.5,
|
||||||
|
px: 2,
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: meta.color,
|
||||||
|
filter: "brightness(0.9)",
|
||||||
|
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
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,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Box, Stack } from "@mui/material";
|
||||||
|
|
||||||
|
export const TypingIndicator = () => {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ y: 0 }}
|
||||||
|
animate={{ y: [-4, 4, -4] }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: i * 0.15,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Blob = ({
|
||||||
|
color,
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||||
|
import { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
|
import type { AgentModel } from "@/lib/chatStream";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||||
|
import { AgentHeader } from "./AgentHeader";
|
||||||
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
|
import { Blob } from "./GlobalChatbox.parts";
|
||||||
|
import type { Props } from "./GlobalChatbox.types";
|
||||||
|
import { PRESET_PROMPTS } from "./GlobalChatbox.utils";
|
||||||
|
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
||||||
|
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||||
|
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||||
|
|
||||||
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [width, setWidth] = useState(520);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
||||||
|
"deepseek/deepseek-v4-pro",
|
||||||
|
);
|
||||||
|
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
|
const hasResetForOpenRef = useRef(false);
|
||||||
|
const theme = useTheme();
|
||||||
|
const { open: openNotification } = useNotification();
|
||||||
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
speechState,
|
||||||
|
speakingMessageId,
|
||||||
|
speak: handleSpeak,
|
||||||
|
pause: handlePauseSpeech,
|
||||||
|
resume: handleResumeSpeech,
|
||||||
|
stop: handleStopSpeech,
|
||||||
|
isSupported: isTtsSupported,
|
||||||
|
} = useSpeechSynthesis();
|
||||||
|
|
||||||
|
const handleSpeechResult = useCallback((text: string) => {
|
||||||
|
composerRef.current?.append(text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isListening,
|
||||||
|
start: startListening,
|
||||||
|
stop: stopListening,
|
||||||
|
isSupported: isSttSupported,
|
||||||
|
} = useSpeechRecognition(handleSpeechResult);
|
||||||
|
|
||||||
|
const handleToolCall = useAgentToolActions();
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
chatSessions,
|
||||||
|
activeSessionId,
|
||||||
|
branchGroups,
|
||||||
|
branchTransition,
|
||||||
|
isHydrating,
|
||||||
|
isStreaming,
|
||||||
|
sessionTitle,
|
||||||
|
sendPrompt,
|
||||||
|
regenerate,
|
||||||
|
editAndResubmit,
|
||||||
|
cycleBranch,
|
||||||
|
abort,
|
||||||
|
createSession,
|
||||||
|
renameSession,
|
||||||
|
removeSession,
|
||||||
|
switchSession,
|
||||||
|
} = useAgentChatSession({
|
||||||
|
projectId: currentProjectId,
|
||||||
|
onToolCall: handleToolCall,
|
||||||
|
onBeforeSend: stopListening,
|
||||||
|
getModel: () => selectedModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom(isStreaming ? "auto" : "smooth");
|
||||||
|
}, [isStreaming, messages, scrollToBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
hasResetForOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasResetForOpenRef.current || isHydrating) return;
|
||||||
|
hasResetForOpenRef.current = true;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
createSession();
|
||||||
|
composerRef.current?.clear();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
composerRef.current?.focus();
|
||||||
|
scrollToBottom("auto");
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [createSession, isHydrating, open, scrollToBottom]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
|
if (isStreaming || isCheckingAuth) return;
|
||||||
|
|
||||||
|
setIsCheckingAuth(true);
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
composerRef.current?.setValue(prompt);
|
||||||
|
openNotification?.({
|
||||||
|
type: "error",
|
||||||
|
message: "登录状态已失效",
|
||||||
|
description: "请重新登录后再发送对话。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendPrompt(prompt);
|
||||||
|
} catch (error) {
|
||||||
|
composerRef.current?.setValue(prompt);
|
||||||
|
openNotification?.({
|
||||||
|
type: "error",
|
||||||
|
message: "登录状态校验失败",
|
||||||
|
description: error instanceof Error ? error.message : "请重新登录后再试。",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingAuth(false);
|
||||||
|
}
|
||||||
|
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]);
|
||||||
|
|
||||||
|
const handleNewConversation = useCallback(() => {
|
||||||
|
handleStopSpeech();
|
||||||
|
stopListening();
|
||||||
|
createSession();
|
||||||
|
composerRef.current?.clear();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
composerRef.current?.focus();
|
||||||
|
scrollToBottom("auto");
|
||||||
|
}, 0);
|
||||||
|
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
||||||
|
|
||||||
|
const handleHistoryToggle = useCallback(() => {
|
||||||
|
setIsHistoryOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectSession = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
composerRef.current?.clear();
|
||||||
|
void switchSession(sessionId);
|
||||||
|
},
|
||||||
|
[switchSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteSession = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
void removeSession(sessionId);
|
||||||
|
},
|
||||||
|
[removeSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameSession = useCallback(
|
||||||
|
(sessionId: string, title: string) => {
|
||||||
|
void renameSession(sessionId, title);
|
||||||
|
},
|
||||||
|
[renameSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameActiveSession = useCallback(
|
||||||
|
(title: string) => {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
void renameSession(activeSessionId, title);
|
||||||
|
},
|
||||||
|
[activeSessionId, renameSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
const newWidth = window.innerWidth - event.clientX;
|
||||||
|
if (newWidth > 360 && newWidth < 800) {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
variant="temporary"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
hideBackdrop
|
||||||
|
disableScrollLock
|
||||||
|
disableEnforceFocus
|
||||||
|
sx={{
|
||||||
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
width: { xs: "100%", sm: width },
|
||||||
|
background: "transparent",
|
||||||
|
boxShadow: "none",
|
||||||
|
overflow: open ? "visible" : "hidden",
|
||||||
|
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
|
||||||
|
pointerEvents: "auto",
|
||||||
|
transition: isResizing ? "none" : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
bgcolor: alpha("#fff", 0.76),
|
||||||
|
backdropFilter: "blur(30px)",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Blob color={alpha(theme.palette.primary.main, 0.28)} size={300} top="-10%" left="-20%" delay={0} />
|
||||||
|
<Blob color={alpha(theme.palette.secondary.main, 0.24)} size={250} top="40%" left="60%" delay={2} />
|
||||||
|
<Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} />
|
||||||
|
|
||||||
|
<AgentHeader
|
||||||
|
sessionTitle={sessionTitle}
|
||||||
|
canRenameSessionTitle={Boolean(activeSessionId)}
|
||||||
|
isHydrating={isHydrating}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isHistoryOpen={isHistoryOpen}
|
||||||
|
onHistoryToggle={handleHistoryToggle}
|
||||||
|
onRenameSessionTitle={handleRenameActiveSession}
|
||||||
|
onNewConversation={handleNewConversation}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, display: "flex", minHeight: 0, position: "relative", overflow: "hidden" }}>
|
||||||
|
<Box
|
||||||
|
onClick={() => setIsHistoryOpen(false)}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
bgcolor: alpha("#000", 0.05),
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
opacity: isHistoryOpen ? 1 : 0,
|
||||||
|
pointerEvents: isHistoryOpen ? "auto" : "none",
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 268,
|
||||||
|
zIndex: 20,
|
||||||
|
transform: isHistoryOpen ? "translateX(0)" : "translateX(-100%)",
|
||||||
|
transition: "transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
boxShadow: isHistoryOpen ? `4px 0 24px ${alpha("#000", 0.08)}` : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={chatSessions}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
isHydrating={isHydrating}
|
||||||
|
onNewSession={() => {
|
||||||
|
handleNewConversation();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
}}
|
||||||
|
onSelectSession={(id) => {
|
||||||
|
handleSelectSession(id);
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
}}
|
||||||
|
onRenameSession={handleRenameSession}
|
||||||
|
onDeleteSession={handleDeleteSession}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
|
||||||
|
<AgentWorkspace
|
||||||
|
messages={messages}
|
||||||
|
branchGroups={branchGroups}
|
||||||
|
branchTransition={branchTransition}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
bottomRef={bottomRef}
|
||||||
|
speakingMessageId={speakingMessageId}
|
||||||
|
speechState={speechState}
|
||||||
|
onSpeak={handleSpeak}
|
||||||
|
onPauseSpeech={handlePauseSpeech}
|
||||||
|
onResumeSpeech={handleResumeSpeech}
|
||||||
|
onStopSpeech={handleStopSpeech}
|
||||||
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={regenerate}
|
||||||
|
onEditResubmit={editAndResubmit}
|
||||||
|
onCycleBranch={cycleBranch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AgentComposer
|
||||||
|
ref={composerRef}
|
||||||
|
isHydrating={isHydrating || isCheckingAuth}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isListening={isListening}
|
||||||
|
isSttSupported={isSttSupported}
|
||||||
|
presets={PRESET_PROMPTS}
|
||||||
|
onSend={handleSend}
|
||||||
|
onAbort={abort}
|
||||||
|
onStartListening={startListening}
|
||||||
|
onStopListening={stopListening}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export type ChatProgress = {
|
||||||
|
id: string;
|
||||||
|
phase: string;
|
||||||
|
status: "running" | "completed" | "error";
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
|
||||||
|
|
||||||
|
export type AgentArtifact = {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
kind: AgentArtifactKind;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
isError?: boolean;
|
||||||
|
progress?: ChatProgress[];
|
||||||
|
artifacts?: AgentArtifact[];
|
||||||
|
branchRootId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BranchState = {
|
||||||
|
activeIndex: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranch = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sessionId?: string;
|
||||||
|
messages: Message[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BranchGroup = {
|
||||||
|
id: string;
|
||||||
|
rootMessageId: string;
|
||||||
|
parentCount: number;
|
||||||
|
activeIndex: number;
|
||||||
|
branches: MessageBranch[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BranchTransition = {
|
||||||
|
rootMessageId: string;
|
||||||
|
parentCount: number;
|
||||||
|
activeBranchId: string;
|
||||||
|
nonce: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeechState = "idle" | "playing" | "paused";
|
||||||
|
|
||||||
|
export type ChatSessionSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
runStatus?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoadedChatState = {
|
||||||
|
sessionId?: string;
|
||||||
|
title?: string;
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
|
messages: Message[];
|
||||||
|
branchGroups: BranchGroup[];
|
||||||
|
isStreaming?: boolean;
|
||||||
|
runStatus?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { BranchGroup, Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
|
export const createId = () =>
|
||||||
|
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
export const PRESET_PROMPTS = [
|
||||||
|
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
||||||
|
"供水服务分区分析。",
|
||||||
|
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
||||||
|
"帮我生成一份今日运行简报,包含问题、原因和建议。",
|
||||||
|
"查询关键 SCADA 点位最近 24 小时的异常波动。",
|
||||||
|
"排查当前管网爆管风险,并说明优先处置建议。",
|
||||||
|
];
|
||||||
|
|
||||||
|
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 cloneMessage = (message: Message): Message => ({
|
||||||
|
...message,
|
||||||
|
progress: message.progress ? [...message.progress] : undefined,
|
||||||
|
artifacts: message.artifacts ? [...message.artifacts] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
|
||||||
|
|
||||||
|
export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
|
||||||
|
branchGroups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
branches: group.branches.map((branch) => ({
|
||||||
|
...branch,
|
||||||
|
messages: cloneMessages(branch.messages),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
createEmptyChatState,
|
||||||
|
saveActiveChatState,
|
||||||
|
} from "./chatStorage";
|
||||||
|
|
||||||
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("chatStorage backend-only persistence", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an empty initial conversation state without backend calls", () => {
|
||||||
|
const loaded = createEmptyChatState();
|
||||||
|
|
||||||
|
expect(loaded).toMatchObject({
|
||||||
|
title: undefined,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a backend conversation when saving the first non-empty state", async () => {
|
||||||
|
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||||
|
if (url.endsWith("/api/v1/agent/chat/session")) {
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ session_id: "chat-new-1" }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) {
|
||||||
|
expect(init?.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
title: "新对话",
|
||||||
|
is_title_manually_edited: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected request ${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedSessionId = await saveActiveChatState(
|
||||||
|
{
|
||||||
|
title: "新对话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "message-2",
|
||||||
|
role: "user",
|
||||||
|
content: "第一条消息",
|
||||||
|
branchRootId: "message-2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(savedSessionId).toBe("chat-new-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BranchGroup,
|
||||||
|
ChatSessionSummary,
|
||||||
|
LoadedChatState,
|
||||||
|
Message,
|
||||||
|
} from "./GlobalChatbox.types";
|
||||||
|
import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils";
|
||||||
|
|
||||||
|
type BackendSessionPayload = {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
created_at?: string | number;
|
||||||
|
updated_at?: string | number;
|
||||||
|
is_streaming?: boolean;
|
||||||
|
run_status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmptyChatState = (): LoadedChatState => ({
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||||
|
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||||
|
|
||||||
|
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
||||||
|
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
||||||
|
|
||||||
|
const hasChatContent = (state: {
|
||||||
|
messages: Message[];
|
||||||
|
branchGroups: BranchGroup[];
|
||||||
|
sessionId?: string;
|
||||||
|
}) =>
|
||||||
|
state.messages.length > 0 ||
|
||||||
|
state.branchGroups.length > 0 ||
|
||||||
|
Boolean(state.sessionId);
|
||||||
|
|
||||||
|
const compareSessionsByAnchorTime = (
|
||||||
|
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
|
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
const createdAtDiff = right.createdAt - left.createdAt;
|
||||||
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
|
|
||||||
|
const updatedAtDiff = right.updatedAt - left.updatedAt;
|
||||||
|
if (updatedAtDiff !== 0) return updatedAtDiff;
|
||||||
|
|
||||||
|
return right.id.localeCompare(left.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toMillis = (value: string | number | undefined) =>
|
||||||
|
typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now();
|
||||||
|
|
||||||
|
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||||
|
|
||||||
|
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||||
|
method: "GET",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
sessions?: BackendSessionPayload[];
|
||||||
|
};
|
||||||
|
return (payload.sessions ?? [])
|
||||||
|
.map((session) => ({
|
||||||
|
id: session.id ?? "",
|
||||||
|
title: normalizeTitle(session.title),
|
||||||
|
createdAt: toMillis(session.created_at),
|
||||||
|
updatedAt: toMillis(session.updated_at),
|
||||||
|
isStreaming: session.is_streaming,
|
||||||
|
runStatus: session.run_status,
|
||||||
|
}))
|
||||||
|
.filter((session) => Boolean(session.id))
|
||||||
|
.sort(compareSessionsByAnchorTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return createEmptyChatState();
|
||||||
|
}
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
is_title_manually_edited?: boolean;
|
||||||
|
session_id?: string;
|
||||||
|
messages?: Message[];
|
||||||
|
branch_groups?: BranchGroup[];
|
||||||
|
is_streaming?: boolean;
|
||||||
|
run_status?: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
title: normalizeTitle(payload.title),
|
||||||
|
isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
|
||||||
|
messages: sanitizeMessages(payload.messages),
|
||||||
|
sessionId: payload.session_id ?? payload.id,
|
||||||
|
branchGroups: sanitizeBranchGroups(payload.branch_groups),
|
||||||
|
isStreaming: payload.is_streaming ?? false,
|
||||||
|
runStatus: payload.run_status,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBackendChatSession = async (payload?: {
|
||||||
|
sessionId?: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: payload?.sessionId,
|
||||||
|
parent_session_id: payload?.parentSessionId,
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
session_id?: string;
|
||||||
|
};
|
||||||
|
const sessionId = body.session_id?.trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new Error("backend did not return session_id");
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBackendChatState = async (
|
||||||
|
sessionId: string,
|
||||||
|
state: LoadedChatState,
|
||||||
|
): Promise<string> => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: normalizeTitle(state.title),
|
||||||
|
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
|
||||||
|
messages: sanitizeMessages(state.messages),
|
||||||
|
branch_groups: sanitizeBranchGroups(state.branchGroups),
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { id?: string; session_id?: string };
|
||||||
|
return payload.id ?? payload.session_id ?? sessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBackendChatSessionTitle = async (
|
||||||
|
sessionId: string,
|
||||||
|
title: string,
|
||||||
|
isTitleManuallyEdited?: boolean,
|
||||||
|
) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
is_title_manually_edited: isTitleManuallyEdited,
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBackendChatSession = async (sessionId: string) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveActiveChatState = async (
|
||||||
|
state: LoadedChatState,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
if (typeof window === "undefined") return state.sessionId;
|
||||||
|
|
||||||
|
if (!hasChatContent(state)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backendSessionId = state.sessionId;
|
||||||
|
if (!backendSessionId) {
|
||||||
|
backendSessionId = await createBackendChatSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedSessionId = await saveBackendChatState(backendSessionId, {
|
||||||
|
...state,
|
||||||
|
sessionId: backendSessionId,
|
||||||
|
});
|
||||||
|
return savedSessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
return await fetchBackendChatSessions();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateChatSessionTitle = async (
|
||||||
|
sessionId: string,
|
||||||
|
title: string,
|
||||||
|
options?: {
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<void> => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const normalizedTitle = title.trim();
|
||||||
|
if (!normalizedTitle) return;
|
||||||
|
await updateBackendChatSessionTitle(
|
||||||
|
sessionId,
|
||||||
|
normalizedTitle,
|
||||||
|
options?.isTitleManuallyEdited,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadChatSessionById = async (
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<LoadedChatState> => {
|
||||||
|
if (typeof window === "undefined") return createEmptyChatState();
|
||||||
|
|
||||||
|
return await fetchBackendChatSession(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteChatSession = async (
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
|
await deleteBackendChatSession(sessionId);
|
||||||
|
const nextActiveSession = (await listChatSessions())[0];
|
||||||
|
return nextActiveSession?.id;
|
||||||
|
};
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAgentChatSession } from "./useAgentChatSession";
|
||||||
|
import { abortAgentChat, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||||
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listChatSessions = jest.fn();
|
||||||
|
const deleteChatSession = jest.fn();
|
||||||
|
const saveActiveChatState = jest.fn();
|
||||||
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("../chatStorage", () => ({
|
||||||
|
createEmptyChatState: jest.fn(() => ({
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
})),
|
||||||
|
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
|
||||||
|
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||||
|
loadChatSessionById: jest.fn(async () => ({
|
||||||
|
title: "已存在会话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
branchGroups: [],
|
||||||
|
})),
|
||||||
|
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
||||||
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useAgentChatSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
listChatSessions.mockReset();
|
||||||
|
deleteChatSession.mockReset();
|
||||||
|
saveActiveChatState.mockReset();
|
||||||
|
updateChatSessionTitle.mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
|
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add a new empty session to history until there is actual chat content", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.sessionTitle).toBe("新对话"));
|
||||||
|
expect(result.current.chatSessions).toEqual([]);
|
||||||
|
expect(result.current.activeSessionId).toBeUndefined();
|
||||||
|
expect(result.current.messages).toEqual([]);
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(listChatSessions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing history entries when creating a blank new session", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "已有会话",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.chatSessions).toEqual([
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "已有会话",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a deleted history entry before the backend delete finishes", async () => {
|
||||||
|
const initialSessions = [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "第一段会话",
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session-2",
|
||||||
|
title: "第二段会话",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined;
|
||||||
|
|
||||||
|
listChatSessions.mockResolvedValue(initialSessions);
|
||||||
|
deleteChatSession.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<string | undefined>((resolve) => {
|
||||||
|
resolveDelete = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.removeSession("session-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.chatSessions).toEqual([
|
||||||
|
expect.objectContaining({ id: "session-1" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
title: "第一段会话",
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveDelete?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.chatSessions).toEqual([
|
||||||
|
expect.objectContaining({ id: "session-1" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a new conversation only after the stream is done", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
emitStreamEvent = onEvent;
|
||||||
|
await new Promise<void>(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
void result.current.sendPrompt("第一条消息");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "token",
|
||||||
|
sessionId: "chat-stream-1",
|
||||||
|
content: "收到",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "chat-stream-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
|
||||||
|
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
||||||
|
sessionId: "chat-stream-1",
|
||||||
|
messages: [
|
||||||
|
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||||
|
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates a backend streaming session and resumes its stream", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
expect(result.current.activeSessionId).toBe("session-loaded");
|
||||||
|
expect(resumeAgentChatStream).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates resumed messages from state, token, and done events", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{ id: "a1", role: "assistant", content: "已有" },
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "token",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
content: "输出",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.messages).toEqual([
|
||||||
|
expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }),
|
||||||
|
expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => {
|
||||||
|
await new Promise<void>(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalizes running progress when aborting an active prompt", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||||
|
({ onEvent, signal }) =>
|
||||||
|
new Promise<void>((_, reject) => {
|
||||||
|
onEvent({
|
||||||
|
type: "progress",
|
||||||
|
sessionId: "session-1",
|
||||||
|
id: "request-received",
|
||||||
|
phase: "start",
|
||||||
|
status: "running",
|
||||||
|
title: "开始分析",
|
||||||
|
startedAt: 1000,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
reject(new Error("aborted"));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current.sendPrompt("测试中断");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.messages.at(-1)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
role: "assistant",
|
||||||
|
content: "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "request-received",
|
||||||
|
status: "completed",
|
||||||
|
durationMs: expect.any(Number),
|
||||||
|
endedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores generated session titles after the title was edited manually", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "session_title",
|
||||||
|
sessionId: "session-1",
|
||||||
|
title: "自动标题",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "done",
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("session-loaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.renameSession("session-loaded", "手动标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("帮我分析一下");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.sessionTitle).toBe("手动标题");
|
||||||
|
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
|
||||||
|
"session-loaded",
|
||||||
|
"自动标题",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
abortAgentChat,
|
||||||
|
forkAgentChat,
|
||||||
|
resumeAgentChatStream,
|
||||||
|
streamAgentChat,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
import type { AgentModel, StreamEvent } from "@/lib/chatStream";
|
||||||
|
import type {
|
||||||
|
AgentArtifact,
|
||||||
|
BranchGroup,
|
||||||
|
BranchTransition,
|
||||||
|
ChatProgress,
|
||||||
|
ChatSessionSummary,
|
||||||
|
LoadedChatState,
|
||||||
|
Message,
|
||||||
|
} from "../GlobalChatbox.types";
|
||||||
|
import {
|
||||||
|
cloneBranchGroups,
|
||||||
|
cloneMessages,
|
||||||
|
createId,
|
||||||
|
} from "../GlobalChatbox.utils";
|
||||||
|
import {
|
||||||
|
createEmptyChatState,
|
||||||
|
deleteChatSession,
|
||||||
|
listChatSessions,
|
||||||
|
loadChatSessionById,
|
||||||
|
saveActiveChatState,
|
||||||
|
updateChatSessionTitle,
|
||||||
|
} from "../chatStorage";
|
||||||
|
|
||||||
|
type UseAgentChatSessionOptions = {
|
||||||
|
projectId?: string | null;
|
||||||
|
onToolCall: (
|
||||||
|
event: StreamEvent & { type: "tool_call" },
|
||||||
|
options: {
|
||||||
|
assistantMessageId: string;
|
||||||
|
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
onBeforeSend?: () => void;
|
||||||
|
getModel?: () => AgentModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PromptRunOptions = {
|
||||||
|
prompt: string;
|
||||||
|
sessionIdOverride?: string;
|
||||||
|
preparedMessages?: Message[];
|
||||||
|
userMessage?: Message;
|
||||||
|
assistantMessage?: Message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPersistedStateKey = (state: LoadedChatState) =>
|
||||||
|
JSON.stringify({
|
||||||
|
title: state.title ?? null,
|
||||||
|
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
||||||
|
sessionId: state.sessionId ?? null,
|
||||||
|
messages: state.messages,
|
||||||
|
branchGroups: state.branchGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
const upsertProgress = (
|
||||||
|
progress: ChatProgress[] | undefined,
|
||||||
|
event: StreamEvent & { type: "progress" },
|
||||||
|
) => {
|
||||||
|
const next = [...(progress ?? [])];
|
||||||
|
const index = next.findIndex((item) => item.id === event.id);
|
||||||
|
const existing = index >= 0 ? next[index] : undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const startedAt = event.startedAt ?? existing?.startedAt;
|
||||||
|
const isRunning = event.status === "running";
|
||||||
|
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
|
||||||
|
const elapsedMs = isRunning
|
||||||
|
? event.elapsedMs ??
|
||||||
|
existing?.elapsedMs ??
|
||||||
|
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
|
||||||
|
: undefined;
|
||||||
|
const elapsedSnapshotAt = isRunning
|
||||||
|
? event.elapsedMs !== undefined
|
||||||
|
? now
|
||||||
|
: existing?.elapsedSnapshotAt ?? now
|
||||||
|
: undefined;
|
||||||
|
const durationMs = !isRunning
|
||||||
|
? event.durationMs ??
|
||||||
|
existing?.durationMs ??
|
||||||
|
(startedAt !== undefined && endedAt !== undefined
|
||||||
|
? Math.max(0, endedAt - startedAt)
|
||||||
|
: undefined)
|
||||||
|
: undefined;
|
||||||
|
const nextItem: ChatProgress = {
|
||||||
|
id: event.id,
|
||||||
|
phase: event.phase,
|
||||||
|
status: event.status,
|
||||||
|
title: event.title,
|
||||||
|
detail: event.detail,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs,
|
||||||
|
elapsedSnapshotAt,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = nextItem;
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
||||||
|
progress?.map((item) => {
|
||||||
|
if (item.status !== "running") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const endedAt = Date.now();
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
status: "completed" as const,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs:
|
||||||
|
item.durationMs ??
|
||||||
|
(item.startedAt !== undefined
|
||||||
|
? Math.max(0, endedAt - item.startedAt)
|
||||||
|
: item.elapsedMs),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const hasVisibleOutput =
|
||||||
|
message.content.trim().length > 0 ||
|
||||||
|
Boolean(message.artifacts?.length) ||
|
||||||
|
Boolean(completedProgress?.length);
|
||||||
|
|
||||||
|
if (!hasVisibleOutput) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: completedProgress,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUserMessage = (content: string, branchRootId?: string): Message => {
|
||||||
|
const id = createId();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
branchRootId: branchRootId ?? id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAssistantMessage = (): Message => ({
|
||||||
|
id: createId(),
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const messagesEqual = (left: Message[], right: Message[]) =>
|
||||||
|
JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
|
||||||
|
export const useAgentChatSession = ({
|
||||||
|
projectId,
|
||||||
|
onToolCall,
|
||||||
|
onBeforeSend,
|
||||||
|
getModel,
|
||||||
|
}: UseAgentChatSessionOptions) => {
|
||||||
|
const hydrationCompletedRef = useRef(false);
|
||||||
|
const hydrationNonceRef = useRef(0);
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
|
||||||
|
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
|
||||||
|
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||||
|
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
|
||||||
|
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
||||||
|
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [isHydrating, setIsHydrating] = useState(true);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | undefined>(undefined);
|
||||||
|
const messagesRef = useRef<Message[]>([]);
|
||||||
|
const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null);
|
||||||
|
const isSessionTitleManuallyEditedRef = useRef(false);
|
||||||
|
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
const titleUpdateNonceRef = useRef(0);
|
||||||
|
const lastPersistedStateKeyRef = useRef(
|
||||||
|
createPersistedStateKey({
|
||||||
|
sessionId: undefined,
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
branchGroups: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionIdRef.current = sessionId;
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current = messages;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||||
|
}, [isSessionTitleManuallyEdited]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const hydrate = async () => {
|
||||||
|
setIsHydrating(true);
|
||||||
|
hydrationCompletedRef.current = false;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
sessionIdRef.current = undefined;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
hydrationCompletedRef.current = true;
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
titleUpdateNonceRef.current += 1;
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages([]);
|
||||||
|
setSessionTitle(undefined);
|
||||||
|
setIsSessionTitleManuallyEdited(false);
|
||||||
|
setSessionId(undefined);
|
||||||
|
setBranchGroups([]);
|
||||||
|
setChatSessions([]);
|
||||||
|
setIsHydrating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await listChatSessions();
|
||||||
|
const streamingSession = sessions.find((session) => session.isStreaming);
|
||||||
|
const loadedState = streamingSession
|
||||||
|
? await loadChatSessionById(streamingSession.id)
|
||||||
|
: createEmptyChatState();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
sessionIdRef.current = loadedState.sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
|
||||||
|
hydrationCompletedRef.current = true;
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
titleUpdateNonceRef.current += 1;
|
||||||
|
|
||||||
|
setMessages(loadedState.messages);
|
||||||
|
setSessionTitle(loadedState.title);
|
||||||
|
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
||||||
|
setSessionId(loadedState.sessionId);
|
||||||
|
setBranchGroups(loadedState.branchGroups);
|
||||||
|
setChatSessions(sessions);
|
||||||
|
if (
|
||||||
|
loadedState.sessionId &&
|
||||||
|
(loadedState.isStreaming || streamingSession?.isStreaming)
|
||||||
|
) {
|
||||||
|
resumeStreamingSessionRef.current?.(loadedState.sessionId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to hydrate chat state:", error);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsHydrating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void hydrate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId || isHydrating || !hydrationCompletedRef.current) return;
|
||||||
|
|
||||||
|
const currentHydrationNonce = hydrationNonceRef.current;
|
||||||
|
const persistTimer = window.setTimeout(() => {
|
||||||
|
if (isStreaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: LoadedChatState = {
|
||||||
|
title: sessionTitle,
|
||||||
|
isTitleManuallyEdited: isSessionTitleManuallyEdited,
|
||||||
|
messages,
|
||||||
|
sessionId,
|
||||||
|
branchGroups,
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStateKey = createPersistedStateKey(state);
|
||||||
|
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveActiveChatState(state)
|
||||||
|
.then((sessionId) => {
|
||||||
|
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||||
|
sessionIdRef.current = sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
...state,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
return listChatSessions();
|
||||||
|
})
|
||||||
|
.then((sessions) => {
|
||||||
|
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||||
|
setChatSessions(sessions);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(persistTimer);
|
||||||
|
};
|
||||||
|
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBranchGroups((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = prev.map((group) => {
|
||||||
|
const rootMessage = messages[group.parentCount];
|
||||||
|
if (
|
||||||
|
!rootMessage ||
|
||||||
|
rootMessage.role !== "user" ||
|
||||||
|
(rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId
|
||||||
|
) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBranch = group.branches[group.activeIndex];
|
||||||
|
if (!activeBranch) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSuffix = cloneMessages(messages.slice(group.parentCount));
|
||||||
|
if (
|
||||||
|
activeBranch.sessionId === sessionId &&
|
||||||
|
messagesEqual(activeBranch.messages, nextSuffix)
|
||||||
|
) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
const branches = group.branches.map((branch, index) =>
|
||||||
|
index === group.activeIndex
|
||||||
|
? { ...branch, sessionId, messages: nextSuffix }
|
||||||
|
: branch,
|
||||||
|
);
|
||||||
|
return { ...group, branches };
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [messages, sessionId]);
|
||||||
|
|
||||||
|
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === messageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
artifacts: [...(message.artifacts ?? []), artifact],
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getLastAssistantMessageId = useCallback((fallback?: string) => {
|
||||||
|
const assistant = [...messagesRef.current]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.role === "assistant");
|
||||||
|
return assistant?.id ?? fallback;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyStreamEvent = useCallback(
|
||||||
|
(
|
||||||
|
event: StreamEvent,
|
||||||
|
options?: {
|
||||||
|
assistantMessageId?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) {
|
||||||
|
sessionIdRef.current = event.sessionId;
|
||||||
|
setSessionId(event.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "state") {
|
||||||
|
const nextMessages = cloneMessages(event.messages as Message[]);
|
||||||
|
messagesRef.current = nextMessages;
|
||||||
|
setMessages(nextMessages);
|
||||||
|
setIsStreaming(event.isStreaming);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId);
|
||||||
|
if (!assistantMessageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "token") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: message.content + event.content,
|
||||||
|
isError: false,
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (event.type === "progress") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? { ...message, progress: upsertProgress(message.progress, event) }
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (event.type === "tool_call") {
|
||||||
|
onToolCall(event, {
|
||||||
|
assistantMessageId,
|
||||||
|
appendArtifact,
|
||||||
|
});
|
||||||
|
} else if (event.type === "session_title") {
|
||||||
|
const nextTitle = event.title.trim();
|
||||||
|
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
|
||||||
|
setSessionTitle(nextTitle);
|
||||||
|
const currentSessionId = sessionIdRef.current;
|
||||||
|
if (currentSessionId) {
|
||||||
|
const currentNonce = ++titleUpdateNonceRef.current;
|
||||||
|
void updateChatSessionTitle(currentSessionId, nextTitle, {
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
})
|
||||||
|
.then(() => listChatSessions())
|
||||||
|
.then((sessions) => {
|
||||||
|
if (titleUpdateNonceRef.current !== currentNonce) return;
|
||||||
|
setChatSessions(sessions);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[GlobalChatbox] Failed to persist session title:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.type === "done") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) => {
|
||||||
|
if (message.id !== assistantMessageId) return message;
|
||||||
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
if (
|
||||||
|
message.content.trim().length === 0 &&
|
||||||
|
!(message.artifacts?.length)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content:
|
||||||
|
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||||
|
progress: completedProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...message, progress: completedProgress };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setIsStreaming(false);
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: message.content || `⚠️ **错误:** ${event.message}`,
|
||||||
|
isError: true,
|
||||||
|
progress: completeRunningProgress(message.progress),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendArtifact, getLastAssistantMessageId, onToolCall],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resumeStreamingSession = useCallback(
|
||||||
|
(nextSessionId: string) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setIsStreaming(true);
|
||||||
|
|
||||||
|
void resumeAgentChatStream({
|
||||||
|
sessionId: nextSessionId,
|
||||||
|
signal: controller.signal,
|
||||||
|
onEvent: (event) => applyStreamEvent(event),
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (abortRef.current === controller) {
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyStreamEvent],
|
||||||
|
);
|
||||||
|
resumeStreamingSessionRef.current = resumeStreamingSession;
|
||||||
|
|
||||||
|
const runPrompt = useCallback(
|
||||||
|
async ({
|
||||||
|
prompt: rawPrompt,
|
||||||
|
sessionIdOverride,
|
||||||
|
preparedMessages,
|
||||||
|
userMessage,
|
||||||
|
assistantMessage,
|
||||||
|
}: PromptRunOptions) => {
|
||||||
|
const prompt = rawPrompt.trim();
|
||||||
|
if (!prompt || isStreaming || isHydrating) return;
|
||||||
|
|
||||||
|
await cancelPromiseRef.current?.catch(() => undefined);
|
||||||
|
onBeforeSend?.();
|
||||||
|
setBranchTransition(null);
|
||||||
|
|
||||||
|
const nextUserMessage = userMessage ?? createUserMessage(prompt);
|
||||||
|
const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
|
||||||
|
const nextMessages =
|
||||||
|
preparedMessages ??
|
||||||
|
[...messages, nextUserMessage, nextAssistantMessage];
|
||||||
|
|
||||||
|
const clonedNextMessages = cloneMessages(nextMessages);
|
||||||
|
setIsStreaming(true);
|
||||||
|
messagesRef.current = clonedNextMessages;
|
||||||
|
setMessages(clonedNextMessages);
|
||||||
|
if (sessionIdOverride !== undefined) {
|
||||||
|
sessionIdRef.current = sessionIdOverride;
|
||||||
|
setSessionId(sessionIdOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamAgentChat({
|
||||||
|
message: prompt,
|
||||||
|
sessionId: sessionIdOverride ?? sessionIdRef.current,
|
||||||
|
model: getModel?.(),
|
||||||
|
signal: controller.signal,
|
||||||
|
onEvent: (event) =>
|
||||||
|
applyStreamEvent(event, {
|
||||||
|
assistantMessageId: nextAssistantMessage.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev
|
||||||
|
.map((message) =>
|
||||||
|
message.id === nextAssistantMessage.id
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(message) =>
|
||||||
|
!(
|
||||||
|
message.id === nextAssistantMessage.id &&
|
||||||
|
message.role === "assistant" &&
|
||||||
|
message.content.trim().length === 0 &&
|
||||||
|
!(message.artifacts?.length) &&
|
||||||
|
!(message.progress?.length)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === nextAssistantMessage.id
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: `⚠️ **错误:** ${String(error)}`,
|
||||||
|
isError: true,
|
||||||
|
progress: completeRunningProgress(message.progress),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsStreaming(false);
|
||||||
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyStreamEvent, getModel, isHydrating, isStreaming, messages, onBeforeSend],
|
||||||
|
);
|
||||||
|
|
||||||
|
const abort = useCallback(() => {
|
||||||
|
const controller = abortRef.current;
|
||||||
|
controller?.abort();
|
||||||
|
setIsStreaming(false);
|
||||||
|
const assistantMessageId = getLastAssistantMessageId();
|
||||||
|
|
||||||
|
if (assistantMessageId) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? finalizeAssistantMessageAfterAbort(message)
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => {
|
||||||
|
console.error("[GlobalChatbox] Failed to abort agent session:", error);
|
||||||
|
});
|
||||||
|
const trackedCancelPromise = cancelPromise.finally(() => {
|
||||||
|
if (cancelPromiseRef.current === trackedCancelPromise) {
|
||||||
|
cancelPromiseRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
|
}, [getLastAssistantMessageId]);
|
||||||
|
|
||||||
|
const createSession = useCallback(() => {
|
||||||
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
const controller = abortRef.current;
|
||||||
|
controller?.abort();
|
||||||
|
setBranchTransition(null);
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
titleUpdateNonceRef.current += 1;
|
||||||
|
sessionIdRef.current = undefined;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
title: "新对话",
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
setMessages([]);
|
||||||
|
setSessionTitle("新对话");
|
||||||
|
setIsSessionTitleManuallyEdited(false);
|
||||||
|
setSessionId(undefined);
|
||||||
|
setBranchGroups([]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}, [isHydrating, isStreaming]);
|
||||||
|
|
||||||
|
const switchSession = useCallback(
|
||||||
|
async (nextSessionId: string) => {
|
||||||
|
if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsHydrating(true);
|
||||||
|
try {
|
||||||
|
const [nextState, sessions] = await Promise.all([
|
||||||
|
loadChatSessionById(nextSessionId),
|
||||||
|
listChatSessions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
titleUpdateNonceRef.current += 1;
|
||||||
|
sessionIdRef.current = nextState.sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages(nextState.messages);
|
||||||
|
setSessionTitle(nextState.title);
|
||||||
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
|
setSessionId(nextState.sessionId);
|
||||||
|
setBranchGroups(nextState.branchGroups);
|
||||||
|
setChatSessions(sessions);
|
||||||
|
if (nextState.sessionId && nextState.isStreaming) {
|
||||||
|
resumeStreamingSession(nextState.sessionId);
|
||||||
|
} else {
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to switch chat session:", error);
|
||||||
|
} finally {
|
||||||
|
setIsHydrating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isHydrating, isStreaming, resumeStreamingSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeSession = useCallback(
|
||||||
|
async (targetSessionId: string) => {
|
||||||
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
setChatSessions((prev) =>
|
||||||
|
prev.filter((session) => session.id !== targetSessionId),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextActiveSessionId = await deleteChatSession(
|
||||||
|
targetSessionId,
|
||||||
|
);
|
||||||
|
const sessions = await listChatSessions();
|
||||||
|
setChatSessions(sessions);
|
||||||
|
|
||||||
|
if (sessionIdRef.current !== targetSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextActiveSessionId) {
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
titleUpdateNonceRef.current += 1;
|
||||||
|
sessionIdRef.current = undefined;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
title: undefined,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages([]);
|
||||||
|
setSessionTitle(undefined);
|
||||||
|
setIsSessionTitleManuallyEdited(false);
|
||||||
|
setSessionId(undefined);
|
||||||
|
setBranchGroups([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsHydrating(true);
|
||||||
|
const [nextState, sessionsAfterDelete] = await Promise.all([
|
||||||
|
loadChatSessionById(nextActiveSessionId),
|
||||||
|
listChatSessions(),
|
||||||
|
]);
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
titleUpdateNonceRef.current += 1;
|
||||||
|
sessionIdRef.current = nextState.sessionId;
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages(nextState.messages);
|
||||||
|
setSessionTitle(nextState.title);
|
||||||
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
|
setSessionId(nextState.sessionId);
|
||||||
|
setBranchGroups(nextState.branchGroups);
|
||||||
|
setChatSessions(sessionsAfterDelete);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to delete chat session:", error);
|
||||||
|
try {
|
||||||
|
setChatSessions(await listChatSessions());
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error("[GlobalChatbox] Failed to refresh chat sessions:", refreshError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsHydrating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isHydrating, isStreaming],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendPrompt = useCallback(
|
||||||
|
async (rawPrompt: string) => {
|
||||||
|
await runPrompt({ prompt: rawPrompt });
|
||||||
|
},
|
||||||
|
[runPrompt],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameSession = useCallback(
|
||||||
|
async (targetSessionId: string, nextTitle: string) => {
|
||||||
|
const normalizedTitle = nextTitle.trim();
|
||||||
|
if (!normalizedTitle || isHydrating) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateChatSessionTitle(targetSessionId, normalizedTitle, {
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
});
|
||||||
|
const sessions = await listChatSessions();
|
||||||
|
setChatSessions(sessions);
|
||||||
|
|
||||||
|
if (sessionIdRef.current === targetSessionId) {
|
||||||
|
setSessionTitle(normalizedTitle);
|
||||||
|
setIsSessionTitleManuallyEdited(true);
|
||||||
|
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||||
|
sessionId: targetSessionId,
|
||||||
|
title: normalizedTitle,
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
messages,
|
||||||
|
branchGroups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[branchGroups, isHydrating, messages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const regenerate = useCallback(async () => {
|
||||||
|
if (isHydrating || isStreaming || messages.length === 0) return;
|
||||||
|
|
||||||
|
let lastUserIndex = messages.length - 1;
|
||||||
|
while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") {
|
||||||
|
lastUserIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastUserIndex < 0) return;
|
||||||
|
|
||||||
|
const lastUser = messages[lastUserIndex];
|
||||||
|
const lastUserContent = lastUser.content;
|
||||||
|
const nextMessages = cloneMessages(messages.slice(0, lastUserIndex));
|
||||||
|
const nextUserMessage = createUserMessage(
|
||||||
|
lastUserContent,
|
||||||
|
lastUser.branchRootId ?? lastUser.id,
|
||||||
|
);
|
||||||
|
const nextAssistantMessage = createAssistantMessage();
|
||||||
|
|
||||||
|
setMessages(nextMessages);
|
||||||
|
await runPrompt({
|
||||||
|
prompt: lastUserContent,
|
||||||
|
preparedMessages: [
|
||||||
|
...nextMessages,
|
||||||
|
nextUserMessage,
|
||||||
|
nextAssistantMessage,
|
||||||
|
],
|
||||||
|
userMessage: nextUserMessage,
|
||||||
|
assistantMessage: nextAssistantMessage,
|
||||||
|
});
|
||||||
|
}, [isHydrating, isStreaming, messages, runPrompt]);
|
||||||
|
|
||||||
|
const editAndResubmit = useCallback(
|
||||||
|
async (messageId: string, newContent: string) => {
|
||||||
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
const trimmedContent = newContent.trim();
|
||||||
|
if (!trimmedContent) return;
|
||||||
|
|
||||||
|
const messageIndex = messages.findIndex((m) => m.id === messageId);
|
||||||
|
if (messageIndex < 0 || messages[messageIndex].role !== "user") return;
|
||||||
|
|
||||||
|
const originalMessage = messages[messageIndex];
|
||||||
|
if (trimmedContent === originalMessage.content.trim()) return;
|
||||||
|
|
||||||
|
const rootMessageId = originalMessage.branchRootId ?? originalMessage.id;
|
||||||
|
const currentSessionId = sessionIdRef.current;
|
||||||
|
const keepMessageCount = messageIndex;
|
||||||
|
const prefix = cloneMessages(messages.slice(0, messageIndex));
|
||||||
|
const originalSuffix = cloneMessages(messages.slice(messageIndex));
|
||||||
|
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
|
||||||
|
|
||||||
|
const nextUserMessage = createUserMessage(trimmedContent, rootMessageId);
|
||||||
|
const nextAssistantMessage = createAssistantMessage();
|
||||||
|
const nextSuffix = [nextUserMessage, nextAssistantMessage];
|
||||||
|
|
||||||
|
setBranchGroups((prev) => {
|
||||||
|
const next = cloneBranchGroups(prev);
|
||||||
|
const groupIndex = next.findIndex(
|
||||||
|
(group) =>
|
||||||
|
group.rootMessageId === rootMessageId && group.parentCount === messageIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groupIndex >= 0) {
|
||||||
|
const group = next[groupIndex];
|
||||||
|
group.branches[group.activeIndex] = {
|
||||||
|
...group.branches[group.activeIndex],
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
messages: originalSuffix,
|
||||||
|
};
|
||||||
|
group.branches.push({
|
||||||
|
id: createId(),
|
||||||
|
label: `分支 ${group.branches.length + 1}`,
|
||||||
|
sessionId: forkedSessionId,
|
||||||
|
messages: cloneMessages(nextSuffix),
|
||||||
|
});
|
||||||
|
group.activeIndex = group.branches.length - 1;
|
||||||
|
} else {
|
||||||
|
next.push({
|
||||||
|
id: rootMessageId,
|
||||||
|
rootMessageId,
|
||||||
|
parentCount: messageIndex,
|
||||||
|
activeIndex: 1,
|
||||||
|
branches: [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
label: "分支 1",
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
messages: originalSuffix,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
label: "分支 2",
|
||||||
|
sessionId: forkedSessionId,
|
||||||
|
messages: cloneMessages(nextSuffix),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionIdRef.current = forkedSessionId;
|
||||||
|
setSessionId(forkedSessionId);
|
||||||
|
await runPrompt({
|
||||||
|
prompt: trimmedContent,
|
||||||
|
sessionIdOverride: forkedSessionId,
|
||||||
|
preparedMessages: [...prefix, ...nextSuffix],
|
||||||
|
userMessage: nextUserMessage,
|
||||||
|
assistantMessage: nextAssistantMessage,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isHydrating, isStreaming, messages, runPrompt],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cycleBranch = useCallback(
|
||||||
|
(rootMessageId: string, direction: -1 | 1) => {
|
||||||
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
setBranchGroups((prev) => {
|
||||||
|
const next = cloneBranchGroups(prev);
|
||||||
|
const group = next.find((item) => item.rootMessageId === rootMessageId);
|
||||||
|
if (!group || group.branches.length < 2) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex =
|
||||||
|
(group.activeIndex + direction + group.branches.length) % group.branches.length;
|
||||||
|
const selectedBranch = group.branches[nextIndex];
|
||||||
|
group.activeIndex = nextIndex;
|
||||||
|
|
||||||
|
const nextMessages = [
|
||||||
|
...cloneMessages(messages.slice(0, group.parentCount)),
|
||||||
|
...cloneMessages(selectedBranch.messages),
|
||||||
|
];
|
||||||
|
setBranchTransition({
|
||||||
|
rootMessageId,
|
||||||
|
parentCount: group.parentCount,
|
||||||
|
activeBranchId: selectedBranch.id,
|
||||||
|
nonce: Date.now(),
|
||||||
|
});
|
||||||
|
sessionIdRef.current = selectedBranch.sessionId;
|
||||||
|
setSessionId(selectedBranch.sessionId);
|
||||||
|
setMessages(nextMessages);
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isHydrating, isStreaming, messages],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
chatSessions,
|
||||||
|
activeSessionId: sessionIdRef.current,
|
||||||
|
branchGroups,
|
||||||
|
branchTransition,
|
||||||
|
isHydrating,
|
||||||
|
isStreaming,
|
||||||
|
sessionTitle,
|
||||||
|
sessionId,
|
||||||
|
sendPrompt,
|
||||||
|
regenerate,
|
||||||
|
editAndResubmit,
|
||||||
|
cycleBranch,
|
||||||
|
abort,
|
||||||
|
createSession,
|
||||||
|
renameSession,
|
||||||
|
removeSession,
|
||||||
|
switchSession,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore";
|
||||||
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types";
|
||||||
|
import {
|
||||||
|
APPLY_LAYER_STYLE_TOOL,
|
||||||
|
describeApplyLayerStyle,
|
||||||
|
parseApplyLayerStylePayload,
|
||||||
|
} from "../toolCallStyleHelpers";
|
||||||
|
|
||||||
|
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
||||||
|
|
||||||
|
type HandleToolCallOptions = {
|
||||||
|
assistantMessageId: string;
|
||||||
|
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_TYPE_MAP: Record<
|
||||||
|
string,
|
||||||
|
{ layer: string; geometryKind: "point" | "line"; label: string }
|
||||||
|
> = {
|
||||||
|
junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
|
||||||
|
junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
|
||||||
|
pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
|
||||||
|
pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
|
||||||
|
valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
|
||||||
|
valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
|
||||||
|
reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
|
||||||
|
reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
|
||||||
|
pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
|
||||||
|
pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
|
||||||
|
tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
|
||||||
|
tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCATE_TOOL_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ layer: string; geometryKind: "point" | "line"; label: string }
|
||||||
|
> = {
|
||||||
|
locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
|
||||||
|
locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
|
||||||
|
locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
|
||||||
|
locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
|
||||||
|
locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
|
||||||
|
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCATE_ID_PARAM_KEYS = [
|
||||||
|
"ids",
|
||||||
|
"id",
|
||||||
|
"feature_ids",
|
||||||
|
"feature_id",
|
||||||
|
"node_ids",
|
||||||
|
"node_id",
|
||||||
|
"junction_ids",
|
||||||
|
"junction_id",
|
||||||
|
"pipe_ids",
|
||||||
|
"pipe_id",
|
||||||
|
"valve_ids",
|
||||||
|
"valve_id",
|
||||||
|
"reservoir_ids",
|
||||||
|
"reservoir_id",
|
||||||
|
"pump_ids",
|
||||||
|
"pump_id",
|
||||||
|
"tank_ids",
|
||||||
|
"tank_id",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const normalizeIds = (params: Record<string, unknown>): string[] => {
|
||||||
|
for (const key of LOCATE_ID_PARAM_KEYS) {
|
||||||
|
const rawValue = params[key];
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
const normalized = rawValue.map((id) => String(id).trim()).filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof rawValue === "string" || typeof rawValue === "number") {
|
||||||
|
const normalized = String(rawValue)
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveScadaFeatureInfos = (params: Record<string, unknown>): [string, string][] => {
|
||||||
|
const rawFeatureInfos = params.feature_infos;
|
||||||
|
if (Array.isArray(rawFeatureInfos)) {
|
||||||
|
const normalizedFeatureInfos = rawFeatureInfos
|
||||||
|
.map((item) => (Array.isArray(item) ? item : null))
|
||||||
|
.filter((item): item is [unknown, unknown] => Boolean(item))
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.filter(([id]) => id.trim().length > 0);
|
||||||
|
if (normalizedFeatureInfos.length > 0) {
|
||||||
|
return normalizedFeatureInfos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDeviceIds =
|
||||||
|
params.device_ids ??
|
||||||
|
params.deviceId ??
|
||||||
|
params.device_id ??
|
||||||
|
params.id ??
|
||||||
|
params.ids;
|
||||||
|
const deviceIds = Array.isArray(rawDeviceIds)
|
||||||
|
? rawDeviceIds.map((id) => String(id))
|
||||||
|
: typeof rawDeviceIds === "string"
|
||||||
|
? rawDeviceIds
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return deviceIds.map((id) => [id, "scada"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTimeRange = (params: Record<string, unknown>) => ({
|
||||||
|
startTime:
|
||||||
|
(params.start_time as string | undefined) ??
|
||||||
|
(params.startTime as string | undefined) ??
|
||||||
|
(params.from as string | undefined) ??
|
||||||
|
(params.start as string | undefined),
|
||||||
|
endTime:
|
||||||
|
(params.end_time as string | undefined) ??
|
||||||
|
(params.endTime as string | undefined) ??
|
||||||
|
(params.to as string | undefined) ??
|
||||||
|
(params.end as string | undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const compactNames = (names: string[]) => {
|
||||||
|
if (!names.length) return "";
|
||||||
|
return names.length > 3
|
||||||
|
? `${names.slice(0, 3).join(", ")} 等 ${names.length} 个`
|
||||||
|
: names.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLocateArtifact = (
|
||||||
|
tool: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): { artifact: Omit<AgentArtifact, "id" | "params" | "tool">; action: ChatToolAction | null } => {
|
||||||
|
const ids = normalizeIds(params);
|
||||||
|
const rawType = params.feature_type;
|
||||||
|
const featureType =
|
||||||
|
typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
|
||||||
|
const config = tool === "locate_features"
|
||||||
|
? FEATURE_TYPE_MAP[featureType]
|
||||||
|
: LOCATE_TOOL_CONFIG[tool];
|
||||||
|
|
||||||
|
return {
|
||||||
|
artifact: {
|
||||||
|
kind: "map",
|
||||||
|
title: config ? `地图定位${config.label}` : "地图定位",
|
||||||
|
description: compactNames(ids),
|
||||||
|
},
|
||||||
|
action: config
|
||||||
|
? {
|
||||||
|
type: "locate_features",
|
||||||
|
ids,
|
||||||
|
layer: config.layer,
|
||||||
|
geometryKind: config.geometryKind,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildToolAction = (
|
||||||
|
tool: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): { action: ChatToolAction | null; kind: AgentArtifactKind; title: string; description?: string } => {
|
||||||
|
if (tool === "show_chart") {
|
||||||
|
return {
|
||||||
|
action: null,
|
||||||
|
kind: "chart",
|
||||||
|
title: (params.title as string | undefined) ?? "生成图表",
|
||||||
|
description: "已生成可视化图表",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === "locate_features" || LOCATE_TOOL_CONFIG[tool]) {
|
||||||
|
const locate = buildLocateArtifact(tool, params);
|
||||||
|
return {
|
||||||
|
action: locate.action,
|
||||||
|
kind: locate.artifact.kind,
|
||||||
|
title: locate.artifact.title,
|
||||||
|
description: locate.artifact.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === "view_history") {
|
||||||
|
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
|
||||||
|
const { startTime, endTime } = resolveTimeRange(params);
|
||||||
|
return {
|
||||||
|
action: {
|
||||||
|
type: "view_history",
|
||||||
|
featureInfos,
|
||||||
|
dataType:
|
||||||
|
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
||||||
|
"realtime",
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
},
|
||||||
|
kind: "panel",
|
||||||
|
title: "打开计算结果曲线",
|
||||||
|
description: compactNames(featureInfos.map(([id]) => id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === "view_scada") {
|
||||||
|
const featureInfos = resolveScadaFeatureInfos(params);
|
||||||
|
const { startTime, endTime } = resolveTimeRange(params);
|
||||||
|
return {
|
||||||
|
action: {
|
||||||
|
type: "view_scada",
|
||||||
|
featureInfos,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
},
|
||||||
|
kind: "panel",
|
||||||
|
title: "打开 SCADA 数据面板",
|
||||||
|
description: compactNames(featureInfos.map(([id]) => id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === "render_junctions") {
|
||||||
|
const renderRef =
|
||||||
|
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: renderRef
|
||||||
|
? {
|
||||||
|
type: "render_junctions",
|
||||||
|
renderRef,
|
||||||
|
sessionId: undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
kind: "map",
|
||||||
|
title: "渲染节点分区",
|
||||||
|
description: renderRef || "渲染引用",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === APPLY_LAYER_STYLE_TOOL) {
|
||||||
|
const payload = parseApplyLayerStylePayload(params);
|
||||||
|
return {
|
||||||
|
action: payload
|
||||||
|
? {
|
||||||
|
type: "apply_layer_style",
|
||||||
|
layerId: payload.layerId,
|
||||||
|
resetToDefault: payload.resetToDefault,
|
||||||
|
styleConfig: payload.styleConfig,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
kind: "map",
|
||||||
|
title: payload?.resetToDefault ? "重置图层样式" : "应用图层样式",
|
||||||
|
description: payload ? describeApplyLayerStyle(payload) : "图层样式",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: null,
|
||||||
|
kind: "tool",
|
||||||
|
title: tool || "工具调用",
|
||||||
|
description: "Agent 已执行工具动作",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAgentToolActions = () => {
|
||||||
|
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(event: ToolCallEvent, options: HandleToolCallOptions) => {
|
||||||
|
const { action, kind, title, description } = buildToolAction(
|
||||||
|
event.tool,
|
||||||
|
event.params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedAction =
|
||||||
|
action?.type === "render_junctions"
|
||||||
|
? { ...action, sessionId: event.sessionId }
|
||||||
|
: action;
|
||||||
|
|
||||||
|
options.appendArtifact(options.assistantMessageId, {
|
||||||
|
id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
tool: event.tool,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
params: event.params,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (normalizedAction) {
|
||||||
|
dispatchToolAction(normalizedAction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatchToolAction],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes";
|
||||||
|
|
||||||
|
export type ApplyLayerStyleActionPayload = {
|
||||||
|
layerId: DefaultLayerStyleId;
|
||||||
|
resetToDefault: boolean;
|
||||||
|
styleConfig?: Partial<StyleConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APPLY_LAYER_STYLE_TOOL = "apply_layer_style";
|
||||||
|
|
||||||
|
const LAYER_LABELS: Record<DefaultLayerStyleId, string> = {
|
||||||
|
junctions: "节点",
|
||||||
|
pipes: "管道",
|
||||||
|
};
|
||||||
|
|
||||||
|
const asString = (value: unknown): string | undefined =>
|
||||||
|
typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
|
||||||
|
const asNumber = (value: unknown): number | undefined =>
|
||||||
|
typeof value === "number" && Number.isFinite(value)
|
||||||
|
? value
|
||||||
|
: typeof value === "string" && value.trim() && Number.isFinite(Number(value))
|
||||||
|
? Number(value)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const asBoolean = (value: unknown): boolean | undefined =>
|
||||||
|
typeof value === "boolean"
|
||||||
|
? value
|
||||||
|
: typeof value === "string"
|
||||||
|
? value === "true"
|
||||||
|
? true
|
||||||
|
: value === "false"
|
||||||
|
? false
|
||||||
|
: undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const asNumberArray = (value: unknown): number[] | undefined =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
.map((item) => asNumber(item))
|
||||||
|
.filter((item): item is number => item !== undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const asStringArray = (value: unknown): string[] | undefined =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
.map((item) => asString(item))
|
||||||
|
.filter((item): item is string => item !== undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
|
||||||
|
const normalized = asString(value)?.toLowerCase();
|
||||||
|
if (normalized === "junctions" || normalized === "pipes") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStyleLayerLabel = (layerId: DefaultLayerStyleId): string =>
|
||||||
|
LAYER_LABELS[layerId];
|
||||||
|
|
||||||
|
export const parseApplyLayerStylePayload = (
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): ApplyLayerStyleActionPayload | null => {
|
||||||
|
const layerId = normalizeStyleLayerId(params.layer_id ?? params.layerId);
|
||||||
|
if (!layerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToDefault = Boolean(
|
||||||
|
asBoolean(params.reset_to_default ?? params.resetToDefault),
|
||||||
|
);
|
||||||
|
const rawStyleConfig =
|
||||||
|
params.style_config && typeof params.style_config === "object"
|
||||||
|
? (params.style_config as Record<string, unknown>)
|
||||||
|
: params.styleConfig && typeof params.styleConfig === "object"
|
||||||
|
? (params.styleConfig as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
|
||||||
|
? {
|
||||||
|
property: asString(rawStyleConfig.property),
|
||||||
|
classificationMethod: asString(
|
||||||
|
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
|
||||||
|
),
|
||||||
|
segments: asNumber(rawStyleConfig.segments),
|
||||||
|
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
|
||||||
|
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
|
||||||
|
minStrokeWidth: asNumber(
|
||||||
|
rawStyleConfig.min_stroke_width ?? rawStyleConfig.minStrokeWidth,
|
||||||
|
),
|
||||||
|
maxStrokeWidth: asNumber(
|
||||||
|
rawStyleConfig.max_stroke_width ?? rawStyleConfig.maxStrokeWidth,
|
||||||
|
),
|
||||||
|
fixedStrokeWidth: asNumber(
|
||||||
|
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
|
||||||
|
),
|
||||||
|
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
|
||||||
|
singlePaletteIndex: asNumber(
|
||||||
|
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
|
||||||
|
),
|
||||||
|
gradientPaletteIndex: asNumber(
|
||||||
|
rawStyleConfig.gradient_palette_index ?? rawStyleConfig.gradientPaletteIndex,
|
||||||
|
),
|
||||||
|
rainbowPaletteIndex: asNumber(
|
||||||
|
rawStyleConfig.rainbow_palette_index ?? rawStyleConfig.rainbowPaletteIndex,
|
||||||
|
),
|
||||||
|
showLabels: asBoolean(rawStyleConfig.show_labels ?? rawStyleConfig.showLabels),
|
||||||
|
showId: asBoolean(rawStyleConfig.show_id ?? rawStyleConfig.showId),
|
||||||
|
opacity: asNumber(rawStyleConfig.opacity),
|
||||||
|
adjustWidthByProperty: asBoolean(
|
||||||
|
rawStyleConfig.adjust_width_by_property ??
|
||||||
|
rawStyleConfig.adjustWidthByProperty,
|
||||||
|
),
|
||||||
|
customBreaks: asNumberArray(
|
||||||
|
rawStyleConfig.custom_breaks ?? rawStyleConfig.customBreaks,
|
||||||
|
),
|
||||||
|
customColors: asStringArray(
|
||||||
|
rawStyleConfig.custom_colors ?? rawStyleConfig.customColors,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const hasStyleOverrides =
|
||||||
|
styleConfig &&
|
||||||
|
Object.values(styleConfig).some((value) =>
|
||||||
|
Array.isArray(value) ? value.length > 0 : value !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resetToDefault && !hasStyleOverrides) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layerId,
|
||||||
|
resetToDefault,
|
||||||
|
styleConfig: hasStyleOverrides ? styleConfig : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeApplyLayerStyle = (
|
||||||
|
payload: ApplyLayerStyleActionPayload,
|
||||||
|
): string => {
|
||||||
|
const layerLabel = getStyleLayerLabel(payload.layerId);
|
||||||
|
if (payload.resetToDefault) {
|
||||||
|
return `${layerLabel} · 重置默认样式`;
|
||||||
|
}
|
||||||
|
const property = payload.styleConfig?.property;
|
||||||
|
return property ? `${layerLabel} · ${property}` : `${layerLabel} · 应用样式`;
|
||||||
|
};
|
||||||
@@ -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,229 @@
|
|||||||
|
"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 { useMap } from "@components/olmap/core/MapComponent";
|
||||||
|
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
|
import AnalysisParameters from "./AnalysisParameters";
|
||||||
|
import SchemeQuery from "./SchemeQuery";
|
||||||
|
import RecognitionResults from "./RecognitionResults";
|
||||||
|
import { applyJunctionAreaRender } from "./applyJunctionAreaRender";
|
||||||
|
import { getAreaColor } from "./utils";
|
||||||
|
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
const areaColors = Object.fromEntries(
|
||||||
|
areaIds.map((areaId) => [areaId, getAreaColor(areaId)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return applyJunctionAreaRender(
|
||||||
|
map,
|
||||||
|
{
|
||||||
|
nodeAreaMap: loadedResult?.node_area_map ?? {},
|
||||||
|
areaIds,
|
||||||
|
areaColors,
|
||||||
|
},
|
||||||
|
{ propertyKey: DMA_AREA_INDEX_PROPERTY },
|
||||||
|
);
|
||||||
|
}, [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,147 @@
|
|||||||
|
import { Map as OlMap, VectorTile } from "ol";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import { FlatStyleLike } from "ol/style/flat";
|
||||||
|
|
||||||
|
import { config } from "@/config/config";
|
||||||
|
import { getAreaColor } from "./utils";
|
||||||
|
|
||||||
|
const JUNCTION_LAYER_VALUE = "junctions";
|
||||||
|
const RENDER_OWNER_KEY = "junction-area-render-owner";
|
||||||
|
|
||||||
|
export type JunctionAreaRenderPayload = {
|
||||||
|
nodeAreaMap: Record<string, string>;
|
||||||
|
areaIds?: string[];
|
||||||
|
areaColors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApplyJunctionAreaRenderOptions = {
|
||||||
|
propertyKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROPERTY_KEY = "junction_area_render_index";
|
||||||
|
|
||||||
|
const getJunctionLayer = (map: OlMap) =>
|
||||||
|
map
|
||||||
|
.getAllLayers()
|
||||||
|
.find(
|
||||||
|
(layer) =>
|
||||||
|
layer instanceof WebGLVectorTileLayer &&
|
||||||
|
layer.get("value") === JUNCTION_LAYER_VALUE,
|
||||||
|
) as WebGLVectorTileLayer | undefined;
|
||||||
|
|
||||||
|
export const applyJunctionAreaRender = (
|
||||||
|
map: OlMap,
|
||||||
|
payload: JunctionAreaRenderPayload,
|
||||||
|
options: ApplyJunctionAreaRenderOptions = {},
|
||||||
|
) => {
|
||||||
|
const propertyKey = options.propertyKey ?? DEFAULT_PROPERTY_KEY;
|
||||||
|
const junctionLayer = getJunctionLayer(map);
|
||||||
|
if (!junctionLayer) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = junctionLayer.getSource() as VectorTileSource | null;
|
||||||
|
if (!source) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = `${propertyKey}-${Date.now().toString(36)}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
|
||||||
|
const normalizedNodeAreaMap = Object.fromEntries(
|
||||||
|
Object.entries(payload.nodeAreaMap ?? {}).map(([nodeId, areaId]) => [
|
||||||
|
String(nodeId),
|
||||||
|
String(areaId),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const areaIds = (
|
||||||
|
payload.areaIds?.length
|
||||||
|
? payload.areaIds
|
||||||
|
: Array.from(new Set(Object.values(normalizedNodeAreaMap)))
|
||||||
|
)
|
||||||
|
.map(String)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (Object.keys(normalizedNodeAreaMap).length === 0 || areaIds.length === 0) {
|
||||||
|
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaIdToIndex = new Map<string, number>();
|
||||||
|
areaIds.forEach((areaId, index) => {
|
||||||
|
areaIdToIndex.set(areaId, index + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeAreaIndexMap = new Map<string, number>();
|
||||||
|
Object.entries(normalizedNodeAreaMap).forEach(([nodeId, areaId]) => {
|
||||||
|
const areaIndex = areaIdToIndex.get(areaId);
|
||||||
|
if (areaIndex !== undefined) {
|
||||||
|
nodeAreaIndexMap.set(nodeId, areaIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyFeatureAreaIndex = (renderFeature: any) => {
|
||||||
|
const featureId = String(renderFeature.get("id") ?? "");
|
||||||
|
const areaIndex = nodeAreaIndexMap.get(featureId);
|
||||||
|
if (areaIndex !== undefined) {
|
||||||
|
renderFeature.properties_[propertyKey] = areaIndex;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceTiles = (source as any).sourceTiles_;
|
||||||
|
if (sourceTiles) {
|
||||||
|
Object.values(sourceTiles).forEach((vectorTile: any) => {
|
||||||
|
const renderFeatures = vectorTile.getFeatures();
|
||||||
|
if (!renderFeatures || renderFeatures.length === 0) return;
|
||||||
|
renderFeatures.forEach((renderFeature: any) => {
|
||||||
|
applyFeatureAreaIndex(renderFeature);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = (event: any) => {
|
||||||
|
try {
|
||||||
|
if (!(event.tile instanceof VectorTile)) return;
|
||||||
|
const renderFeatures = event.tile.getFeatures();
|
||||||
|
if (!renderFeatures || renderFeatures.length === 0) return;
|
||||||
|
renderFeatures.forEach((renderFeature: any) => {
|
||||||
|
applyFeatureAreaIndex(renderFeature);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error applying junction area render:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.on("tileloadend", listener);
|
||||||
|
|
||||||
|
const fillCases: any[] = [];
|
||||||
|
areaIds.forEach((areaId, index) => {
|
||||||
|
fillCases.push(
|
||||||
|
["==", ["get", propertyKey], index + 1],
|
||||||
|
payload.areaColors?.[areaId] ?? getAreaColor(areaId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]);
|
||||||
|
const defaultStrokeColor = String(
|
||||||
|
config.MAP_DEFAULT_STYLE["circle-stroke-color"],
|
||||||
|
);
|
||||||
|
|
||||||
|
junctionLayer.set(RENDER_OWNER_KEY, ownerId);
|
||||||
|
junctionLayer.setStyle({
|
||||||
|
...config.MAP_DEFAULT_STYLE,
|
||||||
|
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
|
||||||
|
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
|
||||||
|
} as FlatStyleLike);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
source.un("tileloadend", listener);
|
||||||
|
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
|
||||||
|
junctionLayer.unset(RENDER_OWNER_KEY, true);
|
||||||
|
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user