Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22afdbf2e8 | |||
| ed9828befe | |||
| 968d798a2a | |||
| 7da0ed0e39 | |||
| 166b45e529 | |||
| e5f13c3d46 | |||
| 36cdb1df8d | |||
| 865e425748 | |||
| 3a36c693cd | |||
| b23cb6acdd | |||
| 2691f42581 | |||
| 34fd5bfb1a | |||
| 40cc355fff | |||
| f7cd5ebfa7 | |||
| d31565d52c | |||
| e32823e4b5 | |||
| 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/
|
||||
**/dist
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
.git
|
||||
npm-debug.log
|
||||
.coverage
|
||||
.coverage.*
|
||||
.env
|
||||
.env*.local
|
||||
README.md
|
||||
docker-compose.yml
|
||||
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
|
||||
*.tsbuildinfo
|
||||
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
|
||||
|
||||
@@ -15,6 +15,18 @@ RUN \
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
|
||||
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
|
||||
ARG NEXT_PUBLIC_BACKEND_URL
|
||||
ARG NEXT_PUBLIC_AGENT_URL
|
||||
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
|
||||
ARG NEXT_PUBLIC_MAP_URL
|
||||
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
||||
ARG NEXT_PUBLIC_MAP_EXTENT
|
||||
ARG NEXT_PUBLIC_NETWORK_NAME
|
||||
ARG NEXT_PUBLIC_MAPBOX_TOKEN
|
||||
ARG NEXT_PUBLIC_TIANDITU_TOKEN
|
||||
|
||||
COPY --from=deps /app/refine/node_modules ./node_modules
|
||||
|
||||
COPY . .
|
||||
@@ -23,7 +35,7 @@ RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/refine/public ./public
|
||||
|
||||
@@ -37,7 +49,7 @@ USER refine
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -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} */
|
||||
const nextConfig = {
|
||||
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "refine.ams3.cdn.digitaloceanspaces.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.svg": {
|
||||
loaders: ["@svgr/webpack"],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
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",
|
||||
"build": "refine build",
|
||||
"start": "refine start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"refine": "refine"
|
||||
"refine": "refine",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
@@ -24,12 +25,10 @@
|
||||
"@mui/x-charts": "^7.29.1",
|
||||
"@mui/x-data-grid": "^7.22.2",
|
||||
"@mui/x-date-pickers": "^8.12.0",
|
||||
"@refinedev/cli": "^2.16.50",
|
||||
"@refinedev/core": "^5.0.8",
|
||||
"@refinedev/devtools": "^2.0.3",
|
||||
"@refinedev/core": "^5.0.12",
|
||||
"@refinedev/kbar": "^2.0.1",
|
||||
"@refinedev/mui": "^8.0.0",
|
||||
"@refinedev/nextjs-router": "^7.0.4",
|
||||
"@refinedev/mui": "^8.0.2",
|
||||
"@refinedev/nextjs-router": "^7.0.5",
|
||||
"@refinedev/react-hook-form": "^5.0.4",
|
||||
"@refinedev/simple-rest": "^6.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
@@ -39,19 +38,32 @@
|
||||
"deck.gl": "^9.1.14",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.5",
|
||||
"framer-motion": "^12.38.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "^15.5.11",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^4.24.5",
|
||||
"ol": "^10.7.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"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": {
|
||||
"@refinedev/cli": "^2.16.52",
|
||||
"@refinedev/devtools": "^2.0.5",
|
||||
"@refinedev/devtools-internal": "^2.0.2",
|
||||
"@refinedev/devtools-server": "^2.0.2",
|
||||
"@refinedev/devtools-shared": "^2.0.2",
|
||||
"@refinedev/devtools-ui": "^2.0.3",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -62,9 +74,10 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^15.0.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
|
||||
@@ -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";
|
||||
|
||||
import MapComponent from "@app/OlMap/MapComponent";
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
|
||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
||||
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
|
||||
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
|
||||
import StyleLegend from "@app/OlMap/Controls/StyleLegend";
|
||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||
import {
|
||||
RAINBOW_COLORS,
|
||||
RISK_BREAKS,
|
||||
|
||||
@@ -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 { cookies } from "next/headers";
|
||||
import React, { Suspense } from "react";
|
||||
import { RefineContext } from "../_refine_context";
|
||||
|
||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||
import { Header } from "@components/header";
|
||||
@@ -33,7 +32,6 @@ export default async function MainLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<RefineContext defaultMode={defaultMode}>
|
||||
<ThemedLayout
|
||||
Header={Header}
|
||||
Title={Title}
|
||||
@@ -48,7 +46,6 @@ export default async function MainLayout({
|
||||
{children}
|
||||
</Suspense>
|
||||
</ThemedLayout>
|
||||
</RefineContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import MapComponent from "@app/OlMap/MapComponent";
|
||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
|
||||
export default function Home() {
|
||||
return (
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import MapComponent from "@app/OlMap/MapComponent";
|
||||
import Timeline from "@app/OlMap/Controls/Timeline";
|
||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import MapComponent from "@app/OlMap/MapComponent";
|
||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||
|
||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||
|
||||
export default function Home() {
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||
|
||||
@@ -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";
|
||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import routerProvider from "@refinedev/nextjs-router";
|
||||
|
||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||
import { dataProvider } from "@providers/data-provider";
|
||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||
import { TbDatabaseEdit } from "react-icons/tb";
|
||||
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
|
||||
import { LuReplace } from "react-icons/lu";
|
||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||
import { TbLocationPin } from "react-icons/tb";
|
||||
import { AiOutlinePartition } from "react-icons/ai";
|
||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
||||
import {
|
||||
MyLocation as MyLocationIcon,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
type RefineContextProps = {
|
||||
defaultMode?: string;
|
||||
@@ -31,7 +36,9 @@ export const RefineContext = (
|
||||
) => {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ProjectProvider>
|
||||
<App {...props} />
|
||||
</ProjectProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
@@ -43,6 +50,11 @@ type AppProps = {
|
||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
|
||||
useEffect(() => {
|
||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
||||
}, [data?.accessToken, setAccessToken]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <span>loading...</span>;
|
||||
@@ -99,6 +111,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
if (data?.user) {
|
||||
const { user } = data;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatar: user.image,
|
||||
};
|
||||
@@ -154,19 +167,64 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "风险分析定位",
|
||||
list: "/risk-analysis-location",
|
||||
name: "Hydraulic Simulation",
|
||||
meta: {
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "风险分析定位",
|
||||
// icon: <MdWater className="w-6 h-6" />,
|
||||
label: "事件模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管网优化分区",
|
||||
list: "/network-partition-optimization",
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
icon: <AiOutlinePartition className="w-6 h-6" />,
|
||||
label: "管网优化分区",
|
||||
parent: "Hydraulic Simulation",
|
||||
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 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
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||
issuer: process.env.KEYCLOAK_ISSUER!,
|
||||
clientId: keycloakClientId,
|
||||
clientSecret: keycloakClientSecret,
|
||||
issuer: keycloakIssuer,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
@@ -19,6 +64,45 @@ const authOptions = {
|
||||
}),
|
||||
],
|
||||
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;
|
||||
|
||||
@@ -6,4 +6,5 @@ body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Container from "@mui/material/Container";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useLogin } from "@refinedev/core";
|
||||
import { ThemedTitle } from "@refinedev/mui";
|
||||
import { Title } from "@components/title";
|
||||
|
||||
export default function Login() {
|
||||
const { mutate: login } = useLogin();
|
||||
@@ -25,13 +26,9 @@ export default function Login() {
|
||||
justifyContent="center"
|
||||
flexDirection="column"
|
||||
>
|
||||
<ThemedTitle
|
||||
collapsed={false}
|
||||
wrapperStyles={{
|
||||
fontSize: "22px",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
/>
|
||||
<Box display="flex" justifyContent="center">
|
||||
<Title collapsed={false} />
|
||||
</Box>
|
||||
<Button
|
||||
style={{ width: "240px" }}
|
||||
size="large"
|
||||
@@ -42,10 +39,12 @@ export default function Login() {
|
||||
</Button>
|
||||
<Typography align="center" color={"text.secondary"} fontSize="12px">
|
||||
Powered by
|
||||
<img
|
||||
<Image
|
||||
style={{ padding: "0 5px" }}
|
||||
alt="Keycloak"
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
Keycloak
|
||||
</Typography>
|
||||
|
||||
@@ -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,521 @@
|
||||
"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 VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||
|
||||
export type AgentComposerHandle = {
|
||||
focus: () => void;
|
||||
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;
|
||||
approvalMode: AgentApprovalMode;
|
||||
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
||||
};
|
||||
|
||||
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||
isHydrating = false,
|
||||
isStreaming,
|
||||
isListening,
|
||||
isSttSupported,
|
||||
presets,
|
||||
onSend,
|
||||
onAbort,
|
||||
onStartListening,
|
||||
onStopListening,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
approvalMode,
|
||||
onApprovalModeChange,
|
||||
}, 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}
|
||||
<FormControl size="small" sx={{ minWidth: 96 }}>
|
||||
<Select
|
||||
value={approvalMode}
|
||||
onChange={(event) =>
|
||||
onApprovalModeChange(event.target.value as AgentApprovalMode)
|
||||
}
|
||||
disabled={isHydrating || isStreaming}
|
||||
aria-label="权限批准模式"
|
||||
renderValue={(val) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
|
||||
{val === "always" ? (
|
||||
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
|
||||
) : (
|
||||
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
|
||||
{val === "always" ? "始终允许" : "请求批准"}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
MenuProps={{
|
||||
anchorOrigin: { vertical: "top", horizontal: "left" },
|
||||
transformOrigin: { vertical: "bottom", horizontal: "left" },
|
||||
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
|
||||
PaperProps: {
|
||||
sx: {
|
||||
mb: 1.5,
|
||||
width: 210,
|
||||
borderRadius: 4,
|
||||
bgcolor: alpha("#fff", 0.9),
|
||||
backdropFilter: "blur(24px)",
|
||||
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}`,
|
||||
"& .MuiList-root": { p: 1 },
|
||||
"& .MuiMenuItem-root": {
|
||||
px: 1.5,
|
||||
py: 1.2,
|
||||
mb: 0.5,
|
||||
borderRadius: 3,
|
||||
alignItems: "flex-start",
|
||||
"&:last-child": { mb: 0 },
|
||||
"&.Mui-selected": {
|
||||
bgcolor: alpha("#00acc1", 0.08),
|
||||
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
|
||||
"& .title": { color: "#00838f" },
|
||||
"& .icon": { color: "#00acc1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
height: 36,
|
||||
borderRadius: "18px",
|
||||
bgcolor: alpha("#fff", 0.6),
|
||||
color: "text.secondary",
|
||||
".MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||
".MuiSelect-select": {
|
||||
py: 0,
|
||||
pl: 1,
|
||||
pr: "28px !important",
|
||||
minHeight: 36,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
|
||||
bgcolor: alpha("#000", 0.06),
|
||||
color: "text.primary",
|
||||
},
|
||||
".MuiSelect-icon": {
|
||||
color: "text.secondary",
|
||||
right: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="request">
|
||||
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>请求批准</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>工具权限逐次确认</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem value="always">
|
||||
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
||||
<Box>
|
||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>始终允许</Typography>
|
||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>自动允许本轮权限请求</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</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,27 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||
|
||||
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
||||
|
||||
export const MarkdownBlock = ({ children }: { children: string }) => {
|
||||
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const selectedText = window.getSelection()?.toString();
|
||||
if (!selectedText) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import TerminalRounded from "@mui/icons-material/TerminalRounded";
|
||||
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||
|
||||
import type { PermissionReply } from "@/lib/chatStream";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
||||
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||
if (permission.permission === "bash") return "执行终端命令";
|
||||
if (permission.permission === "edit") return "修改文件内容";
|
||||
return permission.permission || "工具权限请求";
|
||||
};
|
||||
|
||||
const getPermissionPrimaryValue = (
|
||||
permission: NonNullable<Message["permissions"]>[number],
|
||||
) => {
|
||||
if (typeof permission.target === "string" && permission.target.trim()) {
|
||||
return permission.target.trim();
|
||||
}
|
||||
return permission.patterns[0] ?? permission.permission;
|
||||
};
|
||||
|
||||
const PermissionIcon = ({
|
||||
permission,
|
||||
}: {
|
||||
permission: NonNullable<Message["permissions"]>[number];
|
||||
}) => {
|
||||
if (permission.permission === "bash") {
|
||||
return <TerminalRounded sx={{ fontSize: 22 }} />;
|
||||
}
|
||||
if (permission.permission === "external_directory") {
|
||||
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
|
||||
}
|
||||
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
|
||||
};
|
||||
|
||||
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
|
||||
if (status === "approved_always") return "已始终允许";
|
||||
if (status === "approved_once") return "已允许一次";
|
||||
if (status === "rejected") return "已拒绝";
|
||||
if (status === "aborted") return "已中断";
|
||||
if (status === "error") return "提交失败";
|
||||
if (status === "submitting") return "提交中";
|
||||
return "等待确认";
|
||||
};
|
||||
|
||||
const pendingPermissionColor = "#f9a825";
|
||||
const approvedOncePermissionColor = "#00838f";
|
||||
|
||||
const getPermissionStatusColor = (
|
||||
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||
theme: Theme,
|
||||
) => {
|
||||
if (status === "approved_once") return approvedOncePermissionColor;
|
||||
if (status === "approved_always") return theme.palette.success.main;
|
||||
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||
if (status === "aborted") return theme.palette.text.secondary;
|
||||
return pendingPermissionColor;
|
||||
};
|
||||
|
||||
const getPermissionStatusTextColor = (
|
||||
status: NonNullable<Message["permissions"]>[number]["status"],
|
||||
theme: Theme,
|
||||
) => {
|
||||
if (status === "approved_once") return "#006c78";
|
||||
if (status === "approved_always") return theme.palette.success.dark;
|
||||
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||||
if (status === "aborted") return theme.palette.text.secondary;
|
||||
return "#8a5a00";
|
||||
};
|
||||
|
||||
const PermissionRequestCard = ({
|
||||
permission,
|
||||
isRunning,
|
||||
onReply,
|
||||
}: {
|
||||
permission: NonNullable<Message["permissions"]>[number];
|
||||
isRunning: boolean;
|
||||
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isPending =
|
||||
isRunning && (permission.status === "pending" || permission.status === "error");
|
||||
const isSubmitting = isRunning && permission.status === "submitting";
|
||||
const primaryValue = getPermissionPrimaryValue(permission);
|
||||
const accentColor = getPermissionStatusColor(permission.status, theme);
|
||||
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||
const statusLabel = getPermissionStatusLabel(permission.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||
bgcolor: alpha("#fff", 0.5),
|
||||
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||
backdropFilter: "blur(20px)",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
inset: "10px auto 10px 0",
|
||||
width: 3,
|
||||
borderRadius: "0 999px 999px 0",
|
||||
bgcolor: accentColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
pl: 1.75,
|
||||
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: accentColor,
|
||||
bgcolor: alpha(accentColor, 0.1),
|
||||
border: `1px solid ${alpha(accentColor, 0.16)}`,
|
||||
}}
|
||||
>
|
||||
<PermissionIcon permission={permission} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||
{getPermissionTitle(permission)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={statusLabel}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 800,
|
||||
borderRadius: "12px",
|
||||
bgcolor: alpha(accentColor, 0.12),
|
||||
color: statusTextColor,
|
||||
"& .MuiChip-label": { px: 1 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha("#000", 0.025),
|
||||
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||
请求目标
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
lineHeight: 1.55,
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{primaryValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{permission.error ? (
|
||||
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error.main"
|
||||
sx={{
|
||||
display: "block",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{permission.error}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{isPending || isSubmitting ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disableElevation
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "once")}
|
||||
startIcon={
|
||||
isSubmitting ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 94,
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
bgcolor: "#00838f",
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
|
||||
"&:hover": {
|
||||
bgcolor: "#006c78",
|
||||
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
允许一次
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "always")}
|
||||
startIcon={<PushPinRounded fontSize="small" />}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
color: "#00838f",
|
||||
borderColor: alpha("#00838f", 0.24),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
"&:hover": {
|
||||
borderColor: alpha("#00838f", 0.36),
|
||||
bgcolor: alpha("#00838f", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
始终允许
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReply(permission.requestId, "reject")}
|
||||
startIcon={<BlockRounded fontSize="small" />}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
borderColor: alpha(theme.palette.error.main, 0.22),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
"&:hover": {
|
||||
borderColor: alpha(theme.palette.error.main, 0.34),
|
||||
bgcolor: alpha(theme.palette.error.main, 0.07),
|
||||
},
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const PermissionRequestGroup = ({
|
||||
permissions,
|
||||
isRunning,
|
||||
onReply,
|
||||
}: {
|
||||
permissions: NonNullable<Message["permissions"]>;
|
||||
isRunning: boolean;
|
||||
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
||||
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
|
||||
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
|
||||
const abortedCount = permissions.filter((permission) => permission.status === "aborted").length;
|
||||
const pendingCount = permissions.filter(
|
||||
(permission) =>
|
||||
permission.status === "pending" ||
|
||||
permission.status === "submitting" ||
|
||||
permission.status === "error",
|
||||
).length;
|
||||
const hasPendingPermissions = pendingCount > 0;
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const latestPermissions = permissions.slice(-3);
|
||||
const pendingPermissions = permissions.filter(
|
||||
(permission) =>
|
||||
permission.status === "pending" ||
|
||||
permission.status === "submitting" ||
|
||||
permission.status === "error",
|
||||
);
|
||||
const summaryItems = [
|
||||
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
|
||||
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
|
||||
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
|
||||
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
|
||||
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
||||
];
|
||||
const chipColor =
|
||||
pendingCount > 0
|
||||
? getPermissionStatusColor("pending", theme)
|
||||
: abortedCount > 0
|
||||
? getPermissionStatusColor("aborted", theme)
|
||||
: rejectedCount > 0
|
||||
? getPermissionStatusColor("rejected", theme)
|
||||
: getPermissionStatusColor("approved_always", theme);
|
||||
const chipTextColor =
|
||||
pendingCount > 0
|
||||
? getPermissionStatusTextColor("pending", theme)
|
||||
: abortedCount > 0
|
||||
? getPermissionStatusTextColor("aborted", theme)
|
||||
: rejectedCount > 0
|
||||
? getPermissionStatusTextColor("rejected", theme)
|
||||
: getPermissionStatusTextColor("approved_always", theme);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||
bgcolor: alpha("#fff", 0.46),
|
||||
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
|
||||
backdropFilter: "blur(20px)",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.15,
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s ease",
|
||||
"&:hover": { bgcolor: alpha("#000", 0.025) },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: chipColor,
|
||||
bgcolor: alpha(chipColor, 0.1),
|
||||
border: `1px solid ${alpha(chipColor, 0.15)}`,
|
||||
}}
|
||||
>
|
||||
<VerifiedUserRounded sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||
权限请求
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
gap={0.6}
|
||||
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
|
||||
>
|
||||
{summaryItems.map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0.45,
|
||||
height: 22,
|
||||
px: 0.8,
|
||||
borderRadius: "11px",
|
||||
bgcolor: alpha(item.color, 0.08),
|
||||
border: `1px solid ${alpha(item.color, 0.12)}`,
|
||||
color: "textColor" in item ? item.textColor : item.color,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: "textColor" in item ? item.textColor : item.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Box>
|
||||
<Box component="span">{item.value} 项</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
{isRunning && pendingCount > 0 ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`待确认 ${pendingCount} 项`}
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: "12px",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 800,
|
||||
color: chipTextColor,
|
||||
bgcolor: alpha(chipColor, 0.1),
|
||||
"& .MuiChip-label": { px: 1 },
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={expanded ? "收起权限请求" : "展开权限请求"}
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
color: "text.secondary",
|
||||
bgcolor: alpha("#000", 0.035),
|
||||
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
{!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? (
|
||||
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
|
||||
{latestPermissions.map((permission, index) => {
|
||||
const primaryValue = getPermissionPrimaryValue(permission);
|
||||
const isLast = index === latestPermissions.length - 1;
|
||||
const itemColor = getPermissionStatusColor(permission.status, theme);
|
||||
const itemTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
key={permission.requestId}
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
py: 0.8,
|
||||
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
|
||||
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: itemColor,
|
||||
bgcolor: alpha(itemColor, 0.08),
|
||||
}}
|
||||
>
|
||||
<PermissionIcon permission={permission} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
|
||||
{getPermissionTitle(permission)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
title={primaryValue}
|
||||
sx={{
|
||||
display: "block",
|
||||
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
|
||||
}}
|
||||
>
|
||||
{primaryValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getPermissionStatusLabel(permission.status)}
|
||||
sx={{
|
||||
height: 22,
|
||||
borderRadius: "11px",
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 800,
|
||||
color: itemTextColor,
|
||||
bgcolor: alpha(itemColor, 0.08),
|
||||
"& .MuiChip-label": { px: 0.85 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{!expanded && isRunning && hasPendingPermissions ? (
|
||||
<motion.div
|
||||
key="pending-permissions"
|
||||
initial={{ opacity: 0, y: -10, height: 0 }}
|
||||
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||
{pendingPermissions.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.requestId}
|
||||
permission={permission}
|
||||
isRunning={isRunning}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||
{permissions.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.requestId}
|
||||
permission={permission}
|
||||
isRunning={isRunning}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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,564 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
|
||||
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
const getQuestionStatusLabel = (
|
||||
status: NonNullable<Message["questions"]>[number]["status"],
|
||||
) => {
|
||||
if (status === "answered") return "已回答";
|
||||
if (status === "rejected") return "已跳过";
|
||||
if (status === "error") return "提交失败";
|
||||
if (status === "submitting") return "提交中";
|
||||
return "等待回答";
|
||||
};
|
||||
|
||||
const getQuestionStatusColor = (
|
||||
status: NonNullable<Message["questions"]>[number]["status"],
|
||||
theme: Theme,
|
||||
) => {
|
||||
if (status === "answered") return theme.palette.success.main;
|
||||
if (status === "rejected") return theme.palette.text.secondary;
|
||||
if (status === "error") return theme.palette.error.main;
|
||||
return "#0288d1";
|
||||
};
|
||||
|
||||
const QuestionRequestCard = ({
|
||||
questionRequest,
|
||||
onReply,
|
||||
onReject,
|
||||
}: {
|
||||
questionRequest: NonNullable<Message["questions"]>[number];
|
||||
onReply: (requestId: string, answers: string[][]) => void;
|
||||
onReject: (requestId: string) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isEditable =
|
||||
questionRequest.status === "pending" || questionRequest.status === "error";
|
||||
const isSubmitting = questionRequest.status === "submitting";
|
||||
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
|
||||
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
|
||||
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
|
||||
const [custom, setCustom] = React.useState<Record<number, string>>({});
|
||||
|
||||
const answers = React.useMemo(
|
||||
() =>
|
||||
questionRequest.questions.map((question, index) => {
|
||||
const selectedAnswers = selected[index] ?? [];
|
||||
const isCustomSelected =
|
||||
customSelected[index] === true ||
|
||||
(question.custom !== false && question.options.length === 0);
|
||||
const customAnswer = custom[index]?.trim();
|
||||
return isCustomSelected && customAnswer
|
||||
? [...selectedAnswers, customAnswer]
|
||||
: selectedAnswers;
|
||||
}),
|
||||
[custom, customSelected, questionRequest.questions, selected],
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
isEditable &&
|
||||
questionRequest.questions.length > 0 &&
|
||||
questionRequest.questions.every((_, index) => {
|
||||
const answer = answers[index] ?? [];
|
||||
return answer.some((item) => item.trim().length > 0);
|
||||
});
|
||||
|
||||
const answerSummary = (questionRequest.answers ?? [])
|
||||
.map((answer) => answer.join("、"))
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||
bgcolor: alpha("#fff", 0.52),
|
||||
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||
backdropFilter: "blur(20px)",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
inset: "10px auto 10px 0",
|
||||
width: 3,
|
||||
borderRadius: "0 999px 999px 0",
|
||||
bgcolor: statusColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
pl: 1.75,
|
||||
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: statusColor,
|
||||
bgcolor: alpha(statusColor, 0.1),
|
||||
border: `1px solid ${alpha(statusColor, 0.16)}`,
|
||||
}}
|
||||
>
|
||||
<HelpOutlineRounded sx={{ fontSize: 21 }} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||
需要补充信息
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getQuestionStatusLabel(questionRequest.status)}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 800,
|
||||
borderRadius: "12px",
|
||||
bgcolor: alpha(statusColor, 0.12),
|
||||
color: statusColor,
|
||||
"& .MuiChip-label": { px: 1 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
|
||||
{questionRequest.questions.map((question, index) => {
|
||||
const selectedAnswers = selected[index] ?? [];
|
||||
const isCustomEnabled = question.custom !== false;
|
||||
const isCustomSelected =
|
||||
customSelected[index] === true ||
|
||||
(isCustomEnabled && question.options.length === 0);
|
||||
const setQuestionAnswers = (nextAnswers: string[]) => {
|
||||
setSelected((current) => ({
|
||||
...current,
|
||||
[index]: nextAnswers,
|
||||
}));
|
||||
};
|
||||
const setQuestionCustomSelected = (checked: boolean) => {
|
||||
setCustomSelected((current) => ({
|
||||
...current,
|
||||
[index]: checked,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`${question.header}-${index}`}
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha("#000", 0.025),
|
||||
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||
{question.header || `问题 ${index + 1}`}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }}
|
||||
>
|
||||
{question.question}
|
||||
</Typography>
|
||||
|
||||
{question.options.length ? (
|
||||
<Stack spacing={0.75} sx={{ mt: 1 }}>
|
||||
{question.options.map((option) => {
|
||||
const checked = selectedAnswers.includes(option.label);
|
||||
if (question.multiple) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={option.label}
|
||||
disabled={!isEditable || isSubmitting}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
if (event.target.checked) {
|
||||
setQuestionAnswers([...selectedAnswers, option.label]);
|
||||
} else {
|
||||
setQuestionAnswers(
|
||||
selectedAnswers.filter((item) => item !== option.label),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={750}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
{option.description ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
}
|
||||
sx={{ alignItems: "flex-start", m: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={option.label}
|
||||
size="small"
|
||||
variant={checked ? "contained" : "outlined"}
|
||||
disabled={!isEditable || isSubmitting}
|
||||
onClick={() => {
|
||||
setQuestionAnswers([option.label]);
|
||||
setQuestionCustomSelected(false);
|
||||
}}
|
||||
startIcon={
|
||||
checked ? (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
) : (
|
||||
<RadioButtonUncheckedRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
justifyContent: "flex-start",
|
||||
minHeight: 38,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: 800,
|
||||
bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45),
|
||||
borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22),
|
||||
"&:hover": {
|
||||
bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={800}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
{option.description ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{isCustomEnabled ? (
|
||||
question.multiple ? (
|
||||
<FormControlLabel
|
||||
disabled={!isEditable || isSubmitting}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={isCustomSelected}
|
||||
onChange={(event) =>
|
||||
setQuestionCustomSelected(event.target.checked)
|
||||
}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
color: alpha("#0288d1", 0.55),
|
||||
"&.Mui-checked": { color: "#0288d1" },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
|
||||
<Typography variant="body2" fontWeight={800}>
|
||||
自定义回答
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
minHeight: 38,
|
||||
m: 0,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${
|
||||
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
|
||||
}`,
|
||||
bgcolor: isCustomSelected
|
||||
? alpha("#0288d1", 0.1)
|
||||
: alpha("#fff", 0.45),
|
||||
transition: "background-color 0.18s ease, border-color 0.18s ease",
|
||||
"&:hover": {
|
||||
bgcolor: isCustomSelected
|
||||
? alpha("#0288d1", 0.13)
|
||||
: alpha("#0288d1", 0.07),
|
||||
},
|
||||
"& .MuiFormControlLabel-label": {
|
||||
color: isCustomSelected ? "#0277bd" : "text.primary",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant={isCustomSelected ? "contained" : "outlined"}
|
||||
disabled={!isEditable || isSubmitting}
|
||||
onClick={() => {
|
||||
setQuestionAnswers([]);
|
||||
setQuestionCustomSelected(true);
|
||||
}}
|
||||
startIcon={
|
||||
isCustomSelected ? (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
) : (
|
||||
<EditNoteRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
justifyContent: "flex-start",
|
||||
minHeight: 38,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: 800,
|
||||
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
|
||||
borderColor: isCustomSelected
|
||||
? "#0288d1"
|
||||
: alpha("#0288d1", 0.22),
|
||||
"&:hover": {
|
||||
bgcolor: isCustomSelected
|
||||
? "#0277bd"
|
||||
: alpha("#0288d1", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={800}>
|
||||
自定义回答
|
||||
</Typography>
|
||||
</Box>
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.85,
|
||||
px: 1.15,
|
||||
py: 0.85,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha("#fff", 0.62),
|
||||
border: `1px solid ${alpha("#fff", 0.82)}`,
|
||||
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
|
||||
backdropFilter: "blur(18px)",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
disabled={!isEditable || isSubmitting}
|
||||
value={custom[index] ?? ""}
|
||||
onChange={(event) =>
|
||||
setCustom((current) => ({
|
||||
...current,
|
||||
[index]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="输入自定义回答"
|
||||
InputProps={{
|
||||
disableUnderline: true,
|
||||
sx: {
|
||||
alignItems: "flex-start",
|
||||
fontSize: "0.88rem",
|
||||
lineHeight: 1.55,
|
||||
fontWeight: 500,
|
||||
color: "text.primary",
|
||||
"& textarea::placeholder": {
|
||||
color: alpha(theme.palette.text.primary, 0.38),
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{questionRequest.status === "answered" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="success.main"
|
||||
sx={{
|
||||
display: "block",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.success.main, 0.07),
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
已回答{answerSummary ? `:${answerSummary}` : ""}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{questionRequest.status === "rejected" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha("#000", 0.035),
|
||||
}}
|
||||
>
|
||||
已跳过
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{questionRequest.error ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error.main"
|
||||
sx={{
|
||||
display: "block",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{questionRequest.error}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{isEditable || isSubmitting ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{ px: 1.5, pb: 1.35, pl: 1.75 }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReject(questionRequest.requestId)}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
color: "text.secondary",
|
||||
borderColor: alpha(theme.palette.text.secondary, 0.22),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
}}
|
||||
>
|
||||
跳过
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disableElevation
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={() => onReply(questionRequest.requestId, answers)}
|
||||
startIcon={
|
||||
isSubmitting ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 104,
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
bgcolor: "#0288d1",
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`,
|
||||
"&:hover": {
|
||||
bgcolor: "#0277bd",
|
||||
boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
提交回答
|
||||
</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestionRequestGroup = ({
|
||||
questions,
|
||||
onReply,
|
||||
onReject,
|
||||
}: {
|
||||
questions: NonNullable<Message["questions"]>;
|
||||
onReply: (requestId: string, answers: string[][]) => void;
|
||||
onReject: (requestId: string) => void;
|
||||
}) => (
|
||||
<Stack spacing={1}>
|
||||
{questions.map((question) => (
|
||||
<QuestionRequestCard
|
||||
key={question.requestId}
|
||||
questionRequest={question}
|
||||
onReply={onReply}
|
||||
onReject={onReject}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
|
||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
export const TodoPlanCard = ({
|
||||
todoUpdate,
|
||||
}: {
|
||||
todoUpdate: NonNullable<Message["todos"]>;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const total = todoUpdate.todos.length;
|
||||
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
||||
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
|
||||
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
||||
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
|
||||
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
const isAborted = cancelled > 0 && completed + cancelled === total;
|
||||
const canCollapse = total > 4;
|
||||
const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted);
|
||||
const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos;
|
||||
const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : [];
|
||||
const hiddenCount = expanded ? 0 : collapsibleTodos.length;
|
||||
const latestUpdatedAt = Math.max(
|
||||
todoUpdate.createdAt,
|
||||
...todoUpdate.todos
|
||||
.map((todo) => todo.updatedAt ?? todo.createdAt ?? 0)
|
||||
.filter((value) => value > 0),
|
||||
);
|
||||
const updatedAtLabel =
|
||||
latestUpdatedAt > 0
|
||||
? new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(latestUpdatedAt))
|
||||
: undefined;
|
||||
|
||||
const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => {
|
||||
if (status === "completed") {
|
||||
return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" };
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" };
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" };
|
||||
}
|
||||
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" };
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => {
|
||||
if (priority === "high") return { label: "高优先级", color: "#8a5a00" };
|
||||
if (priority === "medium") return { label: "中优先级", color: "#9a6a16" };
|
||||
if (priority === "low") return { label: "低优先级", color: "#8d7960" };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const statusSummary = isAborted
|
||||
? `${completed} 完成 / ${cancelled} 中止`
|
||||
: [
|
||||
completed ? `${completed} 完成` : null,
|
||||
running ? "1 进行中" : null,
|
||||
pending ? `${pending} 待办` : null,
|
||||
cancelled ? `${cancelled} 中止` : null,
|
||||
].filter(Boolean).join(" / ") || "等待任务";
|
||||
const renderTodoRow = (
|
||||
todo: NonNullable<Message["todos"]>["todos"][number],
|
||||
index: number,
|
||||
) => {
|
||||
const visual = getTodoVisual(todo.status);
|
||||
const priority = getPriorityLabel(todo.priority);
|
||||
return (
|
||||
<Stack
|
||||
key={`${todo.id}-${index}`}
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
spacing={1}
|
||||
sx={{
|
||||
py: 0.8,
|
||||
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
|
||||
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 1.25,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: visual.color,
|
||||
bgcolor: alpha(visual.color, 0.08),
|
||||
mt: 0.1,
|
||||
}}
|
||||
>
|
||||
{visual.icon}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
wordBreak: "break-word",
|
||||
lineHeight: 1.45,
|
||||
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
|
||||
}}
|
||||
>
|
||||
{todo.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}>
|
||||
{priority ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={priority.label}
|
||||
sx={{
|
||||
height: 22,
|
||||
borderRadius: "11px",
|
||||
fontSize: "0.66rem",
|
||||
fontWeight: 800,
|
||||
color: priority.color,
|
||||
bgcolor: alpha(priority.color, 0.045),
|
||||
border: `1px solid ${alpha(priority.color, 0.16)}`,
|
||||
"& .MuiChip-label": { px: 0.75 },
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Chip
|
||||
size="small"
|
||||
label={visual.label}
|
||||
sx={{
|
||||
height: 22,
|
||||
borderRadius: "11px",
|
||||
fontSize: "0.66rem",
|
||||
fontWeight: 800,
|
||||
color: visual.color,
|
||||
bgcolor: alpha(visual.color, 0.08),
|
||||
"& .MuiChip-label": { px: 0.75 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
if (total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${alpha("#00838f", 0.16)}`,
|
||||
bgcolor: alpha("#f8fbfc", 0.82),
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
spacing={1}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (canCollapse) {
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (canCollapse && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
setExpanded((value) => !value);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
px: 1.4,
|
||||
py: 1.15,
|
||||
cursor: canCollapse ? "pointer" : "default",
|
||||
transition: "background-color 0.2s ease",
|
||||
"&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 1.5,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: "#00838f",
|
||||
bgcolor: alpha("#00838f", 0.1),
|
||||
border: `1px solid ${alpha("#00838f", 0.14)}`,
|
||||
}}
|
||||
>
|
||||
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.75}>
|
||||
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||
会话任务
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
|
||||
sx={{
|
||||
height: 20,
|
||||
borderRadius: "10px",
|
||||
fontSize: "0.66rem",
|
||||
fontWeight: 800,
|
||||
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
|
||||
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
|
||||
"& .MuiChip-label": { px: 0.75 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
{canCollapse ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={expanded ? "收起会话任务" : "展开会话任务"}
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
color: "text.secondary",
|
||||
bgcolor: alpha("#000", 0.035),
|
||||
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
bgcolor: alpha("#00838f", 0.1),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${progress}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
bgcolor: isAborted ? theme.palette.text.disabled : "#00838f",
|
||||
transition: "width 0.25s ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}>
|
||||
{pinnedTodos.map((todo, index) => renderTodoRow(todo, index))}
|
||||
{canCollapse ? (
|
||||
<Collapse in={expanded} timeout={220} unmountOnExit={false}>
|
||||
<Stack spacing={0}>
|
||||
{collapsibleTodos.map((todo, index) =>
|
||||
renderTodoRow(todo, index + pinnedTodos.length),
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
) : null}
|
||||
{hiddenCount > 0 ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
pt: 0.8,
|
||||
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
|
||||
}}
|
||||
>
|
||||
还有 {hiddenCount} 项,展开查看全部
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import React, { useMemo } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||
import { TbArrowsSplit2 } from "react-icons/tb";
|
||||
import type { PermissionReply } from "@/lib/chatStream";
|
||||
import {
|
||||
parseAssistantMessageSections,
|
||||
parseContentWithToolCalls,
|
||||
type ContentSegment,
|
||||
} from "./chatMessageSections";
|
||||
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||
import { ChatInlineChart } from "./ChatInlineChart";
|
||||
import type { ChatChartSeries } from "./ChatInlineChart";
|
||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||
import { QuestionRequestGroup } from "./AgentQuestionRequests";
|
||||
import { TodoPlanCard } from "./AgentTodoPlanCard";
|
||||
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||
import StopRounded from "@mui/icons-material/StopRounded";
|
||||
|
||||
type AgentTurnProps = {
|
||||
message: Message;
|
||||
isStreaming: boolean;
|
||||
messageSpeechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
export const AgentTurn = React.memo(
|
||||
({
|
||||
message,
|
||||
isStreaming,
|
||||
messageSpeechState,
|
||||
onSpeak,
|
||||
onPause,
|
||||
onResume,
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onCreateBranch,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: AgentTurnProps) => {
|
||||
const theme = useTheme();
|
||||
const isUser = message.role === "user";
|
||||
const isErrorMessage = Boolean(message.isError);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const isProgressComplete = message.progress?.some(
|
||||
(item) => item.phase === "complete" && item.status === "completed",
|
||||
) ?? false;
|
||||
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
||||
message.progress?.some((item) => item.status === "running") ?? false
|
||||
);
|
||||
|
||||
const parsedAssistantSections = useMemo(
|
||||
() =>
|
||||
!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)}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 5,
|
||||
borderBottomRightRadius: 2,
|
||||
color: "#fff",
|
||||
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
|
||||
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
|
||||
backdropFilter: "blur(10px)",
|
||||
"--chat-md-text": alpha("#fff", 0.96),
|
||||
"--chat-md-heading": "#fff",
|
||||
"--chat-md-link": "#e0f7fa",
|
||||
"--chat-md-link-hover": "#fff",
|
||||
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
|
||||
"--chat-md-inline-code-border": alpha("#fff", 0.1),
|
||||
"--chat-md-inline-code-text": "#fff",
|
||||
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
|
||||
"--chat-md-pre-border": alpha("#fff", 0.1),
|
||||
"--chat-md-pre-text": "#F8FAFC",
|
||||
"--chat-md-quote-border": alpha("#fff", 0.4),
|
||||
"--chat-md-quote-bg": alpha("#fff", 0.05),
|
||||
"--chat-md-quote-text": alpha("#fff", 0.8),
|
||||
}}
|
||||
>
|
||||
<MarkdownBlock>{message.content}</MarkdownBlock>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
{message.permissions?.length ? (
|
||||
<PermissionRequestGroup
|
||||
permissions={message.permissions}
|
||||
isRunning={isProgressRunning}
|
||||
onReply={onReplyPermission}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.questions?.length ? (
|
||||
<QuestionRequestGroup
|
||||
questions={message.questions}
|
||||
onReply={onReplyQuestion}
|
||||
onReject={onRejectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.todos ? (
|
||||
<TodoPlanCard todoUpdate={message.todos} />
|
||||
) : 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 && !isStreaming && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
p: 0.5,
|
||||
borderRadius: "16px",
|
||||
bgcolor: alpha("#fff", 0.8),
|
||||
backdropFilter: "blur(16px)",
|
||||
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="复制">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
// Could add a toast here
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => {
|
||||
onCreateBranch(message.id);
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{!isErrorMessage && isTtsSupported ? (
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
|
||||
{messageSpeechState === "idle" ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
||||
aria-label="朗读消息"
|
||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||
>
|
||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{messageSpeechState === "playing" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PauseRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
{messageSpeechState === "paused" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AgentTurn.displayName = "AgentTurn";
|
||||
@@ -0,0 +1,96 @@
|
||||
/* 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 = {
|
||||
bottomRef: { current: null },
|
||||
speakingMessageId: null,
|
||||
speechState: "idle" as const,
|
||||
onSpeak: jest.fn(),
|
||||
onPauseSpeech: jest.fn(),
|
||||
onResumeSpeech: jest.fn(),
|
||||
onStopSpeech: jest.fn(),
|
||||
isTtsSupported: false,
|
||||
onCreateBranch: jest.fn(),
|
||||
onReplyPermission: jest.fn(),
|
||||
onReplyQuestion: jest.fn(),
|
||||
onRejectQuestion: 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,339 @@
|
||||
"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 { PermissionReply } from "@/lib/chatStream";
|
||||
import type {
|
||||
Message,
|
||||
SpeechState,
|
||||
} from "./GlobalChatbox.types";
|
||||
|
||||
type AgentWorkspaceProps = {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||
speakingMessageId: string | null;
|
||||
speechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
type TurnListProps = {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
speakingMessageId: string | null;
|
||||
speechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||
onRejectQuestion: (requestId: string) => void;
|
||||
};
|
||||
|
||||
const sameMessages = (left: Message[], right: Message[]) =>
|
||||
left.length === right.length &&
|
||||
left.every((message, index) => message === right[index]);
|
||||
|
||||
const TurnListInner = ({
|
||||
messages,
|
||||
isStreaming,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
onSpeak,
|
||||
onPauseSpeech,
|
||||
onResumeSpeech,
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onCreateBranch,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: TurnListProps) => {
|
||||
return (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<AgentTurn
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TurnList = React.memo(
|
||||
TurnListInner,
|
||||
(prevProps, nextProps) =>
|
||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||
prevProps.isStreaming === nextProps.isStreaming &&
|
||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||
prevProps.speechState === nextProps.speechState &&
|
||||
prevProps.onSpeak === nextProps.onSpeak &&
|
||||
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
|
||||
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||||
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||||
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
||||
prevProps.onReplyPermission === nextProps.onReplyPermission &&
|
||||
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
|
||||
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
|
||||
);
|
||||
|
||||
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,
|
||||
isStreaming,
|
||||
bottomRef,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
onSpeak,
|
||||
onPauseSpeech,
|
||||
onResumeSpeech,
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onCreateBranch,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion,
|
||||
}: AgentWorkspaceProps) => {
|
||||
const theme = useTheme();
|
||||
const latestAssistant = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "assistant");
|
||||
const showTypingIndicator =
|
||||
isStreaming &&
|
||||
(!latestAssistant ||
|
||||
(latestAssistant.content.trim().length === 0 &&
|
||||
!(latestAssistant.artifacts?.length)));
|
||||
const streamingMessage =
|
||||
isStreaming && messages.at(-1)?.role === "assistant"
|
||||
? messages.at(-1)
|
||||
: undefined;
|
||||
const historyMessages =
|
||||
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
|
||||
|
||||
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}
|
||||
isStreaming={isStreaming}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
|
||||
{streamingMessage ? (
|
||||
<TurnList
|
||||
messages={[streamingMessage]}
|
||||
isStreaming={isStreaming}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{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,381 @@
|
||||
"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 { AgentApprovalMode, 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 [approvalMode, setApprovalMode] =
|
||||
useState<AgentApprovalMode>("request");
|
||||
|
||||
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,
|
||||
isHydrating,
|
||||
isStreaming,
|
||||
sessionTitle,
|
||||
sendPrompt,
|
||||
createBranch,
|
||||
abort,
|
||||
replyPermission,
|
||||
replyQuestion,
|
||||
rejectQuestion,
|
||||
createSession,
|
||||
renameSession,
|
||||
removeSession,
|
||||
switchSession,
|
||||
} = useAgentChatSession({
|
||||
projectId: currentProjectId,
|
||||
onToolCall: handleToolCall,
|
||||
onBeforeSend: stopListening,
|
||||
getModel: () => selectedModel,
|
||||
getApprovalMode: () => approvalMode,
|
||||
});
|
||||
|
||||
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}
|
||||
isStreaming={isStreaming}
|
||||
bottomRef={bottomRef}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={handleSpeak}
|
||||
onPauseSpeech={handlePauseSpeech}
|
||||
onResumeSpeech={handleResumeSpeech}
|
||||
onStopSpeech={handleStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={createBranch}
|
||||
onReplyPermission={replyPermission}
|
||||
onReplyQuestion={replyQuestion}
|
||||
onRejectQuestion={rejectQuestion}
|
||||
/>
|
||||
|
||||
<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}
|
||||
approvalMode={approvalMode}
|
||||
onApprovalModeChange={setApprovalMode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate,
|
||||
} from "@/lib/chatStream";
|
||||
|
||||
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 AgentPermissionStatus =
|
||||
| "pending"
|
||||
| "submitting"
|
||||
| "approved_once"
|
||||
| "approved_always"
|
||||
| "rejected"
|
||||
| "aborted"
|
||||
| "error";
|
||||
|
||||
export type AgentPermissionRequest = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
permission: string;
|
||||
patterns: string[];
|
||||
target?: string;
|
||||
always: string[];
|
||||
tool?: {
|
||||
messageID: string;
|
||||
callID: string;
|
||||
};
|
||||
createdAt: number;
|
||||
repliedAt?: number;
|
||||
status: AgentPermissionStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
progress?: ChatProgress[];
|
||||
artifacts?: AgentArtifact[];
|
||||
permissions?: AgentPermissionRequest[];
|
||||
questions?: AgentQuestionRequest[];
|
||||
todos?: AgentTodoUpdate;
|
||||
};
|
||||
|
||||
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[];
|
||||
isStreaming?: boolean;
|
||||
runStatus?: string;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cloneMessage } from "./GlobalChatbox.utils";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
describe("cloneMessage", () => {
|
||||
it("normalizes persisted question and todo arrays", () => {
|
||||
const message = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "需要补充信息",
|
||||
questions: [
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "请选择分析范围",
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
todos: {
|
||||
sessionId: "session-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
} as unknown as Message;
|
||||
|
||||
const cloned = cloneMessage(message);
|
||||
|
||||
expect(cloned.questions?.[0]?.questions[0]?.options).toEqual([]);
|
||||
expect(cloned.todos?.todos).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
import type {
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate,
|
||||
} from "@/lib/chatStream";
|
||||
|
||||
export const createId = () =>
|
||||
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
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();
|
||||
|
||||
const normalizeQuestionRequests = (
|
||||
questions: Message["questions"],
|
||||
): Message["questions"] =>
|
||||
Array.isArray(questions)
|
||||
? questions.map((request) => ({
|
||||
...request,
|
||||
questions: Array.isArray(request.questions)
|
||||
? request.questions.map((question) => ({
|
||||
...question,
|
||||
header: typeof question.header === "string" ? question.header : "",
|
||||
question:
|
||||
typeof question.question === "string" ? question.question : "",
|
||||
options: Array.isArray(question.options)
|
||||
? question.options.map((option) => ({
|
||||
label:
|
||||
typeof option.label === "string" ? option.label : "",
|
||||
description:
|
||||
typeof option.description === "string"
|
||||
? option.description
|
||||
: "",
|
||||
}))
|
||||
: [],
|
||||
}))
|
||||
: [],
|
||||
answers: Array.isArray(request.answers)
|
||||
? request.answers.map((answer) =>
|
||||
Array.isArray(answer)
|
||||
? answer.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
)
|
||||
: undefined,
|
||||
} satisfies AgentQuestionRequest))
|
||||
: undefined;
|
||||
|
||||
const normalizeTodoUpdate = (todos: Message["todos"]): Message["todos"] => {
|
||||
if (!todos) return undefined;
|
||||
return {
|
||||
...todos,
|
||||
todos: Array.isArray(todos.todos)
|
||||
? todos.todos.map((todo) => ({ ...todo }))
|
||||
: [],
|
||||
} satisfies AgentTodoUpdate;
|
||||
};
|
||||
|
||||
export const cloneMessage = (message: Message): Message => ({
|
||||
...message,
|
||||
progress: Array.isArray(message.progress) ? [...message.progress] : undefined,
|
||||
artifacts: Array.isArray(message.artifacts) ? [...message.artifacts] : undefined,
|
||||
permissions: Array.isArray(message.permissions)
|
||||
? message.permissions.map((permission) => ({
|
||||
...permission,
|
||||
patterns: Array.isArray(permission.patterns)
|
||||
? [...permission.patterns]
|
||||
: [],
|
||||
always: Array.isArray(permission.always) ? [...permission.always] : [],
|
||||
}))
|
||||
: undefined,
|
||||
questions: normalizeQuestionRequests(message.questions),
|
||||
todos: normalizeTodoUpdate(message.todos),
|
||||
});
|
||||
|
||||
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
|
||||
@@ -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,70 @@
|
||||
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,
|
||||
});
|
||||
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: "第一条消息",
|
||||
},
|
||||
],
|
||||
sessionId: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(savedSessionId).toBe("chat-new-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { config } from "@config/config";
|
||||
|
||||
import type {
|
||||
ChatSessionSummary,
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "./GlobalChatbox.types";
|
||||
import { 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,
|
||||
});
|
||||
|
||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||
|
||||
const hasChatContent = (state: {
|
||||
messages: Message[];
|
||||
sessionId?: string;
|
||||
}) =>
|
||||
state.messages.length > 0 ||
|
||||
Boolean(state.sessionId);
|
||||
|
||||
const compareSessionsByAnchorTime = (
|
||||
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||
) => {
|
||||
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[];
|
||||
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,
|
||||
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),
|
||||
}),
|
||||
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,456 @@
|
||||
import type {
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate,
|
||||
PermissionReply,
|
||||
StreamEvent,
|
||||
} from "@/lib/chatStream";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
ChatProgress,
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "../GlobalChatbox.types";
|
||||
import { createId } from "../GlobalChatbox.utils";
|
||||
|
||||
export const createPersistedStateKey = (state: LoadedChatState) =>
|
||||
JSON.stringify({
|
||||
title: state.title ?? null,
|
||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
||||
sessionId: state.sessionId ?? null,
|
||||
messages: state.messages,
|
||||
});
|
||||
|
||||
export const upsertProgress = (
|
||||
progress: ChatProgress[] | undefined,
|
||||
event: StreamEvent & { type: "progress" },
|
||||
) => {
|
||||
const next = [...(progress ?? [])];
|
||||
const index = next.findIndex((item) => item.id === event.id);
|
||||
const existing = index >= 0 ? next[index] : undefined;
|
||||
const now = Date.now();
|
||||
const startedAt = event.startedAt ?? existing?.startedAt;
|
||||
const isRunning = event.status === "running";
|
||||
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
|
||||
const elapsedMs = isRunning
|
||||
? event.elapsedMs ??
|
||||
existing?.elapsedMs ??
|
||||
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
|
||||
: undefined;
|
||||
const elapsedSnapshotAt = isRunning
|
||||
? event.elapsedMs !== undefined
|
||||
? now
|
||||
: existing?.elapsedSnapshotAt ?? now
|
||||
: undefined;
|
||||
const durationMs = !isRunning
|
||||
? event.durationMs ??
|
||||
existing?.durationMs ??
|
||||
(startedAt !== undefined && endedAt !== undefined
|
||||
? Math.max(0, endedAt - startedAt)
|
||||
: undefined)
|
||||
: undefined;
|
||||
const nextItem: ChatProgress = {
|
||||
id: event.id,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
startedAt,
|
||||
endedAt,
|
||||
elapsedMs,
|
||||
elapsedSnapshotAt,
|
||||
durationMs,
|
||||
};
|
||||
if (index >= 0) {
|
||||
next[index] = nextItem;
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
||||
progress?.map((item) => {
|
||||
if (item.status !== "running") {
|
||||
return item;
|
||||
}
|
||||
const endedAt = Date.now();
|
||||
return {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
endedAt,
|
||||
elapsedMs: undefined,
|
||||
elapsedSnapshotAt: undefined,
|
||||
durationMs:
|
||||
item.durationMs ??
|
||||
(item.startedAt !== undefined
|
||||
? Math.max(0, endedAt - item.startedAt)
|
||||
: item.elapsedMs),
|
||||
};
|
||||
});
|
||||
|
||||
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||
todoUpdate
|
||||
? {
|
||||
...todoUpdate,
|
||||
todos: todoUpdate.todos.map((todo) =>
|
||||
todo.status === "pending" || todo.status === "in_progress"
|
||||
? {
|
||||
...todo,
|
||||
status: "cancelled" as const,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: todo,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
export const upsertPermission = (
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
event: StreamEvent & { type: "permission_request" },
|
||||
) => {
|
||||
const next = [...(permissions ?? [])];
|
||||
const index = next.findIndex((item) => item.requestId === event.requestId);
|
||||
const nextItem: AgentPermissionRequest = {
|
||||
requestId: event.requestId,
|
||||
sessionId: event.sessionId,
|
||||
permission: event.permission,
|
||||
patterns: event.patterns,
|
||||
target: event.target,
|
||||
always: event.always,
|
||||
tool: event.tool,
|
||||
createdAt: event.createdAt,
|
||||
status: "pending",
|
||||
};
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
||||
if (reply === "always") return "approved_always";
|
||||
if (reply === "once") return "approved_once";
|
||||
return "rejected";
|
||||
};
|
||||
|
||||
export const isActionableQuestionRequest = (question: {
|
||||
requestId: string;
|
||||
tool?: AgentQuestionRequest["tool"];
|
||||
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
|
||||
|
||||
export const toQuestionRequest = (
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
status: AgentQuestionRequest["status"] = "pending",
|
||||
): AgentQuestionRequest => ({
|
||||
requestId: event.requestId,
|
||||
sessionId: event.sessionId,
|
||||
questions: event.questions,
|
||||
tool: event.tool,
|
||||
createdAt: event.createdAt,
|
||||
status,
|
||||
});
|
||||
|
||||
export const getQuestionContentSignature = (
|
||||
questions: AgentQuestionRequest["questions"],
|
||||
) =>
|
||||
JSON.stringify(
|
||||
questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
})),
|
||||
multiple: question.multiple ?? false,
|
||||
custom: question.custom !== false,
|
||||
})),
|
||||
);
|
||||
|
||||
export const isSameQuestionRequest = (
|
||||
question: AgentQuestionRequest,
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
) => {
|
||||
if (question.requestId === event.requestId) return true;
|
||||
if (question.tool?.callID && event.tool?.callID) {
|
||||
return question.tool.callID === event.tool.callID;
|
||||
}
|
||||
return (
|
||||
question.status === "pending" &&
|
||||
question.sessionId === event.sessionId &&
|
||||
getQuestionContentSignature(question.questions) ===
|
||||
getQuestionContentSignature(event.questions)
|
||||
);
|
||||
};
|
||||
|
||||
export const isSameQuestionPair = (
|
||||
left: AgentQuestionRequest,
|
||||
right: AgentQuestionRequest,
|
||||
) => {
|
||||
if (left.requestId === right.requestId) return true;
|
||||
if (left.tool?.callID && right.tool?.callID) {
|
||||
return left.tool.callID === right.tool.callID;
|
||||
}
|
||||
return (
|
||||
left.status === "pending" &&
|
||||
right.status === "pending" &&
|
||||
left.sessionId === right.sessionId &&
|
||||
getQuestionContentSignature(left.questions) ===
|
||||
getQuestionContentSignature(right.questions)
|
||||
);
|
||||
};
|
||||
|
||||
export const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
|
||||
const seen: AgentQuestionRequest[] = [];
|
||||
let changed = false;
|
||||
const nextMessages = messages.map((message) => {
|
||||
if (!message.questions?.length) {
|
||||
return message;
|
||||
}
|
||||
const nextQuestions = message.questions.filter((question) => {
|
||||
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
seen.push(question);
|
||||
return true;
|
||||
});
|
||||
if (nextQuestions.length === message.questions.length) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
questions: nextQuestions.length ? nextQuestions : undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextMessages : messages;
|
||||
};
|
||||
|
||||
export const upsertQuestionAcrossMessages = (
|
||||
messages: Message[],
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
assistantMessageId: string,
|
||||
) => {
|
||||
let existing: AgentQuestionRequest | undefined;
|
||||
for (const message of messages) {
|
||||
const match = message.questions?.find((question) =>
|
||||
isSameQuestionRequest(question, event),
|
||||
);
|
||||
if (match) {
|
||||
existing = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const existingStatus: AgentQuestionRequest["status"] | undefined =
|
||||
existing?.status === "submitting" ? "submitting" : undefined;
|
||||
const nextQuestion =
|
||||
existing &&
|
||||
isActionableQuestionRequest(existing) &&
|
||||
!isActionableQuestionRequest(event)
|
||||
? {
|
||||
...existing,
|
||||
sessionId: event.sessionId,
|
||||
questions: event.questions,
|
||||
tool: event.tool ?? existing.tool,
|
||||
createdAt: event.createdAt,
|
||||
status: existingStatus ?? existing.status,
|
||||
}
|
||||
: toQuestionRequest(event, existingStatus ?? "pending");
|
||||
const targetMessageId = existing
|
||||
? messages.find((message) =>
|
||||
message.questions?.some((question) => isSameQuestionRequest(question, event)),
|
||||
)?.id ?? assistantMessageId
|
||||
: assistantMessageId;
|
||||
|
||||
return messages.map((message) => {
|
||||
const filteredQuestions = message.questions?.filter(
|
||||
(question) => !isSameQuestionRequest(question, event),
|
||||
);
|
||||
if (message.id !== targetMessageId) {
|
||||
return filteredQuestions?.length === message.questions?.length
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: filteredQuestions?.length ? filteredQuestions : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
|
||||
return {
|
||||
...message,
|
||||
questions: nextQuestions,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const applyQuestionResponse = (
|
||||
questions: AgentQuestionRequest[] | undefined,
|
||||
event: StreamEvent & { type: "question_response" },
|
||||
) =>
|
||||
(questions ?? []).map((question) =>
|
||||
question.requestId === event.requestId
|
||||
? {
|
||||
...question,
|
||||
status: event.rejected ? "rejected" as const : "answered" as const,
|
||||
answers: event.answers ?? question.answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: question,
|
||||
);
|
||||
|
||||
export const createTodoUpdateFromEvent = (
|
||||
event: StreamEvent & { type: "todo_update" },
|
||||
): AgentTodoUpdate => ({
|
||||
sessionId: event.sessionId,
|
||||
messageId: event.messageId,
|
||||
todos: event.todos,
|
||||
createdAt: event.createdAt,
|
||||
});
|
||||
|
||||
export const normalizeSessionTodos = (
|
||||
messages: Message[],
|
||||
nextTodoUpdate?: AgentTodoUpdate,
|
||||
targetAssistantMessageId?: string,
|
||||
) => {
|
||||
let latestTodoUpdate = nextTodoUpdate;
|
||||
if (!latestTodoUpdate) {
|
||||
for (const message of messages) {
|
||||
if (message.todos) {
|
||||
latestTodoUpdate = message.todos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestTodoUpdate) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const targetMessageId =
|
||||
targetAssistantMessageId ??
|
||||
[...messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
if (!targetMessageId) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextMessages = messages.map((message) => {
|
||||
if (message.id === targetMessageId) {
|
||||
if (message.todos === latestTodoUpdate) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
todos: latestTodoUpdate,
|
||||
};
|
||||
}
|
||||
if (!message.todos) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
todos: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return changed ? nextMessages : messages;
|
||||
};
|
||||
|
||||
export const abortOpenPermissionsAfterAbort = (
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
) => {
|
||||
if (!permissions?.length) return permissions;
|
||||
let changed = false;
|
||||
const nextPermissions = permissions.map((permission) => {
|
||||
if (
|
||||
permission.status !== "pending" &&
|
||||
permission.status !== "submitting" &&
|
||||
permission.status !== "error"
|
||||
) {
|
||||
return permission;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...permission,
|
||||
status: "aborted" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextPermissions : permissions;
|
||||
};
|
||||
|
||||
export const rejectOpenQuestionsAfterAbort = (
|
||||
questions: AgentQuestionRequest[] | undefined,
|
||||
) => {
|
||||
if (!questions?.length) return questions;
|
||||
let changed = false;
|
||||
const nextQuestions = questions.map((question) => {
|
||||
if (
|
||||
question.status !== "pending" &&
|
||||
question.status !== "submitting" &&
|
||||
question.status !== "error"
|
||||
) {
|
||||
return question;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...question,
|
||||
status: "rejected" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextQuestions : questions;
|
||||
};
|
||||
|
||||
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||
const completedProgress = completeRunningProgress(message.progress);
|
||||
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
||||
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||
const hasVisibleOutput =
|
||||
message.content.trim().length > 0 ||
|
||||
Boolean(message.artifacts?.length) ||
|
||||
Boolean(abortedPermissions?.length) ||
|
||||
Boolean(rejectedQuestions?.length) ||
|
||||
Boolean(completedProgress?.length) ||
|
||||
Boolean(cancelledTodos);
|
||||
|
||||
if (!hasVisibleOutput) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: message.content || "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: completedProgress,
|
||||
permissions: abortedPermissions,
|
||||
questions: rejectedQuestions,
|
||||
todos: cancelledTodos,
|
||||
};
|
||||
};
|
||||
|
||||
export const createUserMessage = (content: string): Message => {
|
||||
const id = createId();
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
content,
|
||||
};
|
||||
};
|
||||
|
||||
export const createAssistantMessage = (): Message => ({
|
||||
id: createId(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { useAgentChatSession } from "./useAgentChatSession";
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
resumeAgentChatStream,
|
||||
streamAgentChat,
|
||||
} from "@/lib/chatStream";
|
||||
import type { StreamEvent } from "@/lib/chatStream";
|
||||
|
||||
jest.mock("@/lib/chatStream", () => ({
|
||||
abortAgentChat: jest.fn(async () => undefined),
|
||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||
replyAgentPermission: jest.fn(async () => undefined),
|
||||
replyAgentQuestion: jest.fn(async () => undefined),
|
||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||
streamAgentChat: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const listChatSessions = jest.fn();
|
||||
const deleteChatSession = jest.fn();
|
||||
const saveActiveChatState = jest.fn();
|
||||
const updateChatSessionTitle = jest.fn();
|
||||
|
||||
jest.mock("../chatStorage", () => ({
|
||||
createEmptyChatState: jest.fn(() => ({
|
||||
title: undefined,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
})),
|
||||
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
|
||||
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||
loadChatSessionById: jest.fn(async () => ({
|
||||
title: "已存在会话",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: "session-loaded",
|
||||
})),
|
||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||
}));
|
||||
|
||||
describe("useAgentChatSession", () => {
|
||||
beforeEach(() => {
|
||||
listChatSessions.mockReset();
|
||||
deleteChatSession.mockReset();
|
||||
saveActiveChatState.mockReset();
|
||||
updateChatSessionTitle.mockReset();
|
||||
jest.mocked(abortAgentChat).mockReset();
|
||||
jest.mocked(forkAgentChat).mockReset();
|
||||
jest.mocked(replyAgentPermission).mockReset();
|
||||
jest.mocked(replyAgentQuestion).mockReset();
|
||||
jest.mocked(resumeAgentChatStream).mockReset();
|
||||
jest.mocked(streamAgentChat).mockReset();
|
||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||
deleteChatSession.mockImplementation(async () => undefined);
|
||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
describe("useAgentChatSession actions", () => {
|
||||
it("tracks permission requests and submits replies", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||
emitStreamEvent = onEvent;
|
||||
await new Promise<void>(() => undefined);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendPrompt("删除临时文件");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "permission_request",
|
||||
sessionId: "session-1",
|
||||
requestId: "perm-1",
|
||||
permission: "bash",
|
||||
patterns: ["rm *"],
|
||||
target: "rm tmp.txt",
|
||||
always: ["rm *"],
|
||||
createdAt: 123,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages.at(-1)?.permissions).toEqual([
|
||||
expect.objectContaining({
|
||||
requestId: "perm-1",
|
||||
sessionId: "session-1",
|
||||
status: "pending",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.replyPermission("perm-1", "once");
|
||||
});
|
||||
|
||||
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
|
||||
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
requestId: "perm-1",
|
||||
status: "approved_once",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("finalizes running progress when aborting an active prompt", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||
({ onEvent, signal }) =>
|
||||
new Promise<void>((_, reject) => {
|
||||
onEvent({
|
||||
type: "progress",
|
||||
sessionId: "session-1",
|
||||
id: "request-received",
|
||||
phase: "start",
|
||||
status: "running",
|
||||
title: "开始分析",
|
||||
startedAt: 1000,
|
||||
} satisfies StreamEvent);
|
||||
onEvent({
|
||||
type: "todo_update",
|
||||
sessionId: "session-1",
|
||||
todos: [
|
||||
{
|
||||
id: "todo-1",
|
||||
content: "分析水位",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
id: "todo-2",
|
||||
content: "生成建议",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
createdAt: 1001,
|
||||
} satisfies StreamEvent);
|
||||
onEvent({
|
||||
type: "permission_request",
|
||||
sessionId: "session-1",
|
||||
requestId: "perm-abort",
|
||||
permission: "bash",
|
||||
patterns: ["npm test"],
|
||||
target: "npm test",
|
||||
always: ["npm test"],
|
||||
createdAt: 1002,
|
||||
} satisfies StreamEvent);
|
||||
onEvent({
|
||||
type: "question_request",
|
||||
sessionId: "session-1",
|
||||
requestId: "question-abort",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "请选择范围",
|
||||
options: [{ label: "城区", description: "中心城区" }],
|
||||
},
|
||||
],
|
||||
createdAt: 1003,
|
||||
} satisfies StreamEvent);
|
||||
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
act(() => {
|
||||
void result.current.sendPrompt("测试中断");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.abort();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||
|
||||
expect(result.current.messages.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: [
|
||||
expect.objectContaining({
|
||||
id: "request-received",
|
||||
status: "completed",
|
||||
durationMs: expect.any(Number),
|
||||
endedAt: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
todos: expect.objectContaining({
|
||||
todos: [
|
||||
expect.objectContaining({
|
||||
id: "todo-1",
|
||||
status: "cancelled",
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "todo-2",
|
||||
status: "cancelled",
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
permissions: [
|
||||
expect.objectContaining({
|
||||
requestId: "perm-abort",
|
||||
status: "aborted",
|
||||
repliedAt: expect.any(Number),
|
||||
error: undefined,
|
||||
}),
|
||||
],
|
||||
questions: [
|
||||
expect.objectContaining({
|
||||
requestId: "question-abort",
|
||||
status: "rejected",
|
||||
repliedAt: expect.any(Number),
|
||||
error: undefined,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
it("ignores generated session titles after the title was edited manually", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "session_title",
|
||||
sessionId: "session-1",
|
||||
title: "自动标题",
|
||||
});
|
||||
onEvent({
|
||||
type: "done",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("session-loaded");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.renameSession("session-loaded", "手动标题");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendPrompt("帮我分析一下");
|
||||
});
|
||||
|
||||
expect(result.current.sessionTitle).toBe("手动标题");
|
||||
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
|
||||
"session-loaded",
|
||||
"自动标题",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply a late generated title to a newly created session", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||
let resolveStream: (() => void) | undefined;
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||
emitStreamEvent = onEvent;
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveStream = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendPrompt("帮我分析一下");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "done",
|
||||
sessionId: "old-session",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.createSession();
|
||||
});
|
||||
|
||||
expect(result.current.sessionTitle).toBe("新对话");
|
||||
|
||||
await act(async () => {
|
||||
emitStreamEvent?.({
|
||||
type: "session_title",
|
||||
sessionId: "old-session",
|
||||
title: "旧请求标题",
|
||||
});
|
||||
resolveStream?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.sessionTitle).toBe("新对话");
|
||||
expect(updateChatSessionTitle).toHaveBeenCalledWith(
|
||||
"old-session",
|
||||
"旧请求标题",
|
||||
{ isTitleManuallyEdited: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("forks a copied conversation from an assistant message", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendPrompt("第一轮");
|
||||
});
|
||||
|
||||
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createBranch(firstAssistantMessageId);
|
||||
});
|
||||
|
||||
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
|
||||
expect(result.current.activeSessionId).toBe("forked-session");
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "第一轮",
|
||||
}),
|
||||
);
|
||||
expect(result.current.messages[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
}),
|
||||
);
|
||||
expect(streamAgentChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,791 @@
|
||||
"use client";
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import { useAgentChatSession } from "./useAgentChatSession";
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
resumeAgentChatStream,
|
||||
streamAgentChat,
|
||||
} from "@/lib/chatStream";
|
||||
import type { StreamEvent } from "@/lib/chatStream";
|
||||
|
||||
jest.mock("@/lib/chatStream", () => ({
|
||||
abortAgentChat: jest.fn(async () => undefined),
|
||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||
replyAgentPermission: jest.fn(async () => undefined),
|
||||
replyAgentQuestion: jest.fn(async () => undefined),
|
||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||
streamAgentChat: jest.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const listChatSessions = jest.fn();
|
||||
const deleteChatSession = jest.fn();
|
||||
const saveActiveChatState = jest.fn();
|
||||
const updateChatSessionTitle = jest.fn();
|
||||
|
||||
jest.mock("../chatStorage", () => ({
|
||||
createEmptyChatState: jest.fn(() => ({
|
||||
title: undefined,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
})),
|
||||
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
|
||||
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||
loadChatSessionById: jest.fn(async () => ({
|
||||
title: "已存在会话",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: "session-loaded",
|
||||
})),
|
||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||
}));
|
||||
|
||||
describe("useAgentChatSession", () => {
|
||||
beforeEach(() => {
|
||||
listChatSessions.mockReset();
|
||||
deleteChatSession.mockReset();
|
||||
saveActiveChatState.mockReset();
|
||||
updateChatSessionTitle.mockReset();
|
||||
jest.mocked(abortAgentChat).mockReset();
|
||||
jest.mocked(forkAgentChat).mockReset();
|
||||
jest.mocked(replyAgentPermission).mockReset();
|
||||
jest.mocked(replyAgentQuestion).mockReset();
|
||||
jest.mocked(resumeAgentChatStream).mockReset();
|
||||
jest.mocked(streamAgentChat).mockReset();
|
||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||
deleteChatSession.mockImplementation(async () => undefined);
|
||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
describe("useAgentChatSession lifecycle and resume", () => {
|
||||
it("does not add a new empty session to history until there is actual chat content", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
act(() => {
|
||||
void result.current.createSession();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.sessionTitle).toBe("新对话"));
|
||||
expect(result.current.chatSessions).toEqual([]);
|
||||
expect(result.current.activeSessionId).toBeUndefined();
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(listChatSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps existing history entries when creating a blank new session", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-1",
|
||||
title: "已有会话",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
act(() => {
|
||||
void result.current.createSession();
|
||||
});
|
||||
|
||||
expect(result.current.chatSessions).toEqual([
|
||||
{
|
||||
id: "session-1",
|
||||
title: "已有会话",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a deleted history entry before the backend delete finishes", async () => {
|
||||
const initialSessions = [
|
||||
{
|
||||
id: "session-1",
|
||||
title: "第一段会话",
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
},
|
||||
{
|
||||
id: "session-2",
|
||||
title: "第二段会话",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined;
|
||||
|
||||
listChatSessions.mockResolvedValue(initialSessions);
|
||||
deleteChatSession.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
resolveDelete = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
act(() => {
|
||||
void result.current.removeSession("session-2");
|
||||
});
|
||||
|
||||
expect(result.current.chatSessions).toEqual([
|
||||
expect.objectContaining({ id: "session-1" }),
|
||||
]);
|
||||
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-1",
|
||||
title: "第一段会话",
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
resolveDelete?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.chatSessions).toEqual([
|
||||
expect.objectContaining({ id: "session-1" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists a new conversation only after the stream is done", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||
emitStreamEvent = onEvent;
|
||||
await new Promise<void>(() => undefined);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
void result.current.sendPrompt("第一条消息");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "token",
|
||||
sessionId: "chat-stream-1",
|
||||
content: "收到",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "done",
|
||||
sessionId: "chat-stream-1",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
|
||||
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
||||
sessionId: "chat-stream-1",
|
||||
messages: [
|
||||
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows shared todo state only on the latest assistant message in a session", async () => {
|
||||
listChatSessions.mockResolvedValue([]);
|
||||
jest.mocked(streamAgentChat)
|
||||
.mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "todo_update",
|
||||
sessionId: "session-1",
|
||||
todos: [
|
||||
{
|
||||
id: "todo-1",
|
||||
content: "创建任务列表",
|
||||
status: "in_progress",
|
||||
},
|
||||
],
|
||||
createdAt: 1000,
|
||||
});
|
||||
onEvent({
|
||||
type: "done",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
})
|
||||
.mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "todo_update",
|
||||
sessionId: "session-1",
|
||||
todos: [
|
||||
{
|
||||
id: "todo-1",
|
||||
content: "创建任务列表",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "todo-2",
|
||||
content: "更新任务状态",
|
||||
status: "in_progress",
|
||||
},
|
||||
],
|
||||
createdAt: 2000,
|
||||
});
|
||||
onEvent({
|
||||
type: "done",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendPrompt("创建任务");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendPrompt("更新任务");
|
||||
});
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||
|
||||
const assistantMessages = result.current.messages.filter(
|
||||
(message) => message.role === "assistant",
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
expect(assistantMessages[0].todos).toBeUndefined();
|
||||
expect(assistantMessages[1].todos).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
createdAt: 2000,
|
||||
todos: [
|
||||
expect.objectContaining({
|
||||
id: "todo-1",
|
||||
status: "completed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "todo-2",
|
||||
status: "in_progress",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hydrates a backend streaming session and resumes its stream", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-streaming",
|
||||
title: "运行中",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
isStreaming: true,
|
||||
runStatus: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.activeSessionId).toBe("session-loaded");
|
||||
expect(resumeAgentChatStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-loaded",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates resumed messages from state, token, and done events", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-streaming",
|
||||
title: "运行中",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
isStreaming: true,
|
||||
},
|
||||
]);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "state",
|
||||
sessionId: "session-loaded",
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "继续分析" },
|
||||
{ id: "a1", role: "assistant", content: "已有" },
|
||||
],
|
||||
isStreaming: true,
|
||||
runStatus: "running",
|
||||
});
|
||||
onEvent({
|
||||
type: "token",
|
||||
sessionId: "session-loaded",
|
||||
content: "输出",
|
||||
});
|
||||
onEvent({
|
||||
type: "done",
|
||||
sessionId: "session-loaded",
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(false));
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }),
|
||||
expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies question responses to the message that owns the request", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-streaming",
|
||||
title: "运行中",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
isStreaming: true,
|
||||
},
|
||||
]);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "state",
|
||||
sessionId: "session-loaded",
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "继续分析" },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "需要确认",
|
||||
questions: [
|
||||
{
|
||||
requestId: "q-1",
|
||||
sessionId: "session-loaded",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "选择范围",
|
||||
options: [],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
createdAt: 123,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "后续消息" },
|
||||
],
|
||||
isStreaming: true,
|
||||
runStatus: "running",
|
||||
});
|
||||
onEvent({
|
||||
type: "question_response",
|
||||
sessionId: "session-loaded",
|
||||
requestId: "q-1",
|
||||
answers: [["城区"]],
|
||||
rejected: false,
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
expect(result.current.messages[1].questions?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
requestId: "q-1",
|
||||
status: "answered",
|
||||
answers: [["城区"]],
|
||||
}),
|
||||
);
|
||||
expect(result.current.messages[2].questions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deduplicates question requests across assistant messages", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-streaming",
|
||||
title: "运行中",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
isStreaming: true,
|
||||
},
|
||||
]);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "state",
|
||||
sessionId: "session-loaded",
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "继续分析" },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "需要确认",
|
||||
questions: [
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-loaded",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
createdAt: 123,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "后续消息" },
|
||||
],
|
||||
isStreaming: true,
|
||||
runStatus: "running",
|
||||
});
|
||||
onEvent({
|
||||
type: "question_request",
|
||||
sessionId: "session-loaded",
|
||||
requestId: "call-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
createdAt: 456,
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
const allQuestions = result.current.messages.flatMap(
|
||||
(message) => message.questions ?? [],
|
||||
);
|
||||
expect(allQuestions).toHaveLength(1);
|
||||
expect(result.current.messages[1].questions?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
requestId: "question-1",
|
||||
tool: expect.objectContaining({ callID: "call-1" }),
|
||||
}),
|
||||
);
|
||||
expect(result.current.messages[2].questions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-streaming",
|
||||
title: "运行中",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
isStreaming: true,
|
||||
},
|
||||
]);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "state",
|
||||
sessionId: "session-loaded",
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "继续分析" },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "需要确认",
|
||||
questions: [
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-loaded",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
createdAt: 123,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
isStreaming: true,
|
||||
runStatus: "running",
|
||||
});
|
||||
onEvent({
|
||||
type: "question_request",
|
||||
sessionId: "session-loaded",
|
||||
requestId: "call-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
createdAt: 456,
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
const allQuestions = result.current.messages.flatMap(
|
||||
(message) => message.questions ?? [],
|
||||
);
|
||||
expect(allQuestions).toHaveLength(1);
|
||||
expect(allQuestions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
requestId: "question-1",
|
||||
tool: expect.objectContaining({ callID: "call-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates persisted duplicate questions from state events", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-streaming",
|
||||
title: "运行中",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
isStreaming: true,
|
||||
},
|
||||
]);
|
||||
const duplicateQuestion = {
|
||||
sessionId: "session-loaded",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
createdAt: 123,
|
||||
status: "pending" as const,
|
||||
};
|
||||
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||
onEvent({
|
||||
type: "state",
|
||||
sessionId: "session-loaded",
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "继续分析" },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "需要确认",
|
||||
questions: [{ ...duplicateQuestion, requestId: "question-1" }],
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "后续消息",
|
||||
questions: [{ ...duplicateQuestion, requestId: "call-1" }],
|
||||
},
|
||||
],
|
||||
isStreaming: true,
|
||||
runStatus: "running",
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||
|
||||
expect(
|
||||
result.current.messages.flatMap((message) => message.questions ?? []),
|
||||
).toHaveLength(1);
|
||||
expect(result.current.messages[1].questions).toHaveLength(1);
|
||||
expect(result.current.messages[2].questions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
|
||||
listChatSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-streaming",
|
||||
title: "运行中",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
isStreaming: true,
|
||||
},
|
||||
]);
|
||||
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => {
|
||||
await new Promise<void>(() => undefined);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentChatSession({
|
||||
projectId: "project-1",
|
||||
onToolCall: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isStreaming).toBe(true));
|
||||
|
||||
act(() => {
|
||||
result.current.abort();
|
||||
});
|
||||
|
||||
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Tests for useAgentChatSession are split by behavior boundary.
|
||||
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
|
||||
@@ -0,0 +1,949 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
|
||||
import { cloneMessages } from "../GlobalChatbox.utils";
|
||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
|
||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||
|
||||
export const useAgentChatSession = ({
|
||||
projectId,
|
||||
onToolCall,
|
||||
onBeforeSend,
|
||||
getModel,
|
||||
getApprovalMode,
|
||||
}: 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 [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
||||
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: [],
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
hydrationCompletedRef.current = true;
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
setMessages([]);
|
||||
setSessionTitle(undefined);
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
setSessionId(undefined);
|
||||
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(
|
||||
normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)),
|
||||
);
|
||||
setSessionTitle(loadedState.title);
|
||||
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
||||
setSessionId(loadedState.sessionId);
|
||||
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,
|
||||
};
|
||||
|
||||
const currentStateKey = createPersistedStateKey(state);
|
||||
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
void saveActiveChatState(state)
|
||||
.then((sessionId) => {
|
||||
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||
sessionIdRef.current = sessionId;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
...state,
|
||||
sessionId,
|
||||
});
|
||||
return listChatSessions();
|
||||
})
|
||||
.then((sessions) => {
|
||||
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||
setChatSessions(sessions);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
||||
});
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(persistTimer);
|
||||
};
|
||||
}, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
|
||||
|
||||
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
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 (
|
||||
event.type !== "session_title" &&
|
||||
"sessionId" in event &&
|
||||
event.sessionId &&
|
||||
event.sessionId !== sessionIdRef.current
|
||||
) {
|
||||
sessionIdRef.current = event.sessionId;
|
||||
setSessionId(event.sessionId);
|
||||
}
|
||||
|
||||
if (event.type === "state") {
|
||||
const nextMessages = normalizeSessionTodos(
|
||||
dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])),
|
||||
);
|
||||
messagesRef.current = nextMessages;
|
||||
setMessages(nextMessages);
|
||||
setIsStreaming(event.isStreaming);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "session_title") {
|
||||
const nextTitle = event.title.trim();
|
||||
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
const targetSessionId = event.sessionId || currentSessionId;
|
||||
if (targetSessionId === currentSessionId) {
|
||||
setSessionTitle(nextTitle);
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
sessionId: targetSessionId,
|
||||
title: nextTitle,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: messagesRef.current,
|
||||
});
|
||||
}
|
||||
if (targetSessionId) {
|
||||
const currentNonce = ++titleUpdateNonceRef.current;
|
||||
void updateChatSessionTitle(targetSessionId, nextTitle, {
|
||||
isTitleManuallyEdited: false,
|
||||
})
|
||||
.then(() => listChatSessions())
|
||||
.then((sessions) => {
|
||||
if (titleUpdateNonceRef.current !== currentNonce) return;
|
||||
setChatSessions(sessions);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[GlobalChatbox] Failed to persist session title:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId);
|
||||
if (!assistantMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 === "permission_request") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
permissions: upsertPermission(message.permissions, event),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "permission_response") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
if (message.id !== assistantMessageId || !message.permissions?.length) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === event.requestId
|
||||
? {
|
||||
...permission,
|
||||
status: toPermissionStatus(event.reply),
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: permission,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} else if (event.type === "question_request") {
|
||||
setMessages((prev) =>
|
||||
upsertQuestionAcrossMessages(prev, event, assistantMessageId),
|
||||
);
|
||||
} else if (event.type === "question_response") {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.questions?.some((question) => question.requestId === event.requestId)
|
||||
? {
|
||||
...message,
|
||||
questions: applyQuestionResponse(message.questions, event),
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "todo_update") {
|
||||
setMessages((prev) =>
|
||||
normalizeSessionTodos(
|
||||
prev,
|
||||
createTodoUpdateFromEvent(event),
|
||||
assistantMessageId,
|
||||
),
|
||||
);
|
||||
} 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),
|
||||
todos: cancelRunningTodos(message.todos),
|
||||
}
|
||||
: 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?.();
|
||||
|
||||
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?.(),
|
||||
approvalMode: getApprovalMode?.(),
|
||||
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
|
||||
? finalizeAssistantMessageAfterAbort(message)
|
||||
: message,
|
||||
)
|
||||
.filter(
|
||||
(message) =>
|
||||
!(
|
||||
message.id === nextAssistantMessage.id &&
|
||||
message.role === "assistant" &&
|
||||
message.content.trim().length === 0 &&
|
||||
!(message.artifacts?.length) &&
|
||||
!(message.progress?.length) &&
|
||||
!message.todos
|
||||
),
|
||||
),
|
||||
);
|
||||
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,
|
||||
getApprovalMode,
|
||||
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 replyPermission = useCallback(
|
||||
async (requestId: string, reply: PermissionReply) => {
|
||||
const target = messagesRef.current
|
||||
.flatMap((message) => message.permissions ?? [])
|
||||
.find((permission) => permission.requestId === requestId);
|
||||
if (!target || target.status === "submitting") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === requestId
|
||||
? { ...permission, status: "submitting", error: undefined }
|
||||
: permission,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await replyAgentPermission(target.sessionId, requestId, reply);
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === requestId
|
||||
? {
|
||||
...permission,
|
||||
status: toPermissionStatus(reply),
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: permission,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) =>
|
||||
permission.requestId === requestId
|
||||
? {
|
||||
...permission,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
: permission,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const replyQuestion = useCallback(
|
||||
async (requestId: string, answers: string[][]) => {
|
||||
const target = messagesRef.current
|
||||
.flatMap((message) => message.questions ?? [])
|
||||
.find((question) => question.requestId === requestId);
|
||||
if (!target || target.status === "submitting") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? { ...question, status: "submitting", error: undefined }
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await replyAgentQuestion(target.sessionId, requestId, answers);
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "answered",
|
||||
answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const rejectQuestion = useCallback(
|
||||
async (requestId: string) => {
|
||||
const target = messagesRef.current
|
||||
.flatMap((message) => message.questions ?? [])
|
||||
.find((question) => question.requestId === requestId);
|
||||
if (!target || target.status === "submitting") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? { ...question, status: "submitting", error: undefined }
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await rejectAgentQuestion(target.sessionId, requestId);
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "rejected",
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
!message.questions?.some((question) => question.requestId === requestId)
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: message.questions.map((question) =>
|
||||
question.requestId === requestId
|
||||
? {
|
||||
...question,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
: question,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const createSession = useCallback(() => {
|
||||
if (isHydrating || isStreaming) return;
|
||||
|
||||
const controller = abortRef.current;
|
||||
controller?.abort();
|
||||
hydrationNonceRef.current += 1;
|
||||
titleUpdateNonceRef.current += 1;
|
||||
sessionIdRef.current = undefined;
|
||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
||||
title: "新对话",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
});
|
||||
setMessages([]);
|
||||
setSessionTitle("新对话");
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
setSessionId(undefined);
|
||||
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);
|
||||
setMessages(nextState.messages);
|
||||
setSessionTitle(nextState.title);
|
||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||
setSessionId(nextState.sessionId);
|
||||
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,
|
||||
});
|
||||
setMessages([]);
|
||||
setSessionTitle(undefined);
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
setSessionId(undefined);
|
||||
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);
|
||||
setMessages(nextState.messages);
|
||||
setSessionTitle(nextState.title);
|
||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||
setSessionId(nextState.sessionId);
|
||||
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,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||
}
|
||||
},
|
||||
[isHydrating, messages],
|
||||
);
|
||||
|
||||
const createBranch = useCallback(
|
||||
async (messageId: string) => {
|
||||
if (isHydrating || isStreaming) return;
|
||||
|
||||
const assistantIndex = messages.findIndex(
|
||||
(message) => message.id === messageId && message.role === "assistant",
|
||||
);
|
||||
if (assistantIndex < 0) return;
|
||||
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
const keepMessageCount = assistantIndex + 1;
|
||||
const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount));
|
||||
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
|
||||
|
||||
sessionIdRef.current = forkedSessionId;
|
||||
setSessionId(forkedSessionId);
|
||||
messagesRef.current = copiedMessages;
|
||||
setMessages(copiedMessages);
|
||||
setIsSessionTitleManuallyEdited(false);
|
||||
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
||||
setSessionTitle(forkTitle);
|
||||
try {
|
||||
await saveActiveChatState({
|
||||
title: forkTitle,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: copiedMessages,
|
||||
sessionId: forkedSessionId,
|
||||
});
|
||||
setChatSessions(await listChatSessions());
|
||||
} catch (error) {
|
||||
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
||||
}
|
||||
},
|
||||
[isHydrating, isStreaming, messages, sessionTitle],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
chatSessions,
|
||||
activeSessionId: sessionIdRef.current,
|
||||
isHydrating,
|
||||
isStreaming,
|
||||
sessionTitle,
|
||||
sessionId,
|
||||
sendPrompt,
|
||||
createBranch,
|
||||
abort,
|
||||
replyPermission,
|
||||
replyQuestion,
|
||||
rejectQuestion,
|
||||
createSession,
|
||||
renameSession,
|
||||
removeSession,
|
||||
switchSession,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream";
|
||||
import type { AgentArtifact, Message } from "../GlobalChatbox.types";
|
||||
|
||||
export type UseAgentChatSessionOptions = {
|
||||
projectId?: string | null;
|
||||
onToolCall: (
|
||||
event: StreamEvent & { type: "tool_call" },
|
||||
options: {
|
||||
assistantMessageId: string;
|
||||
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
||||
},
|
||||
) => void;
|
||||
onBeforeSend?: () => void;
|
||||
getModel?: () => AgentModel;
|
||||
getApprovalMode?: () => AgentApprovalMode;
|
||||
};
|
||||
|
||||
export type PromptRunOptions = {
|
||||
prompt: string;
|
||||
sessionIdOverride?: string;
|
||||
preparedMessages?: Message[];
|
||||
userMessage?: Message;
|
||||
assistantMessage?: Message;
|
||||
};
|
||||
@@ -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 DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
||||
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
||||
import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
|
||||
import Logout from "@mui/icons-material/Logout";
|
||||
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
||||
import ChatOutlined from "@mui/icons-material/ChatOutlined";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useGetIdentity } from "@refinedev/core";
|
||||
import { useGetIdentity, useLogout } from "@refinedev/core";
|
||||
import { HamburgerMenu, RefineThemedLayoutHeaderProps } from "@refinedev/mui";
|
||||
import React, { useContext } from "react";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { ProjectSelector } from "@components/project/ProjectSelector";
|
||||
import { GlobalChatbox } from "@components/chat/GlobalChatbox";
|
||||
import { setMapExtent, setMapWorkspace, setNetworkName } from "@config/config";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
|
||||
type IUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
sticky = true,
|
||||
}) => {
|
||||
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 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 (
|
||||
<AppBar position={sticky ? "sticky" : "relative"}>
|
||||
<Toolbar>
|
||||
@@ -42,19 +95,56 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
>
|
||||
<IconButton
|
||||
{/* <IconButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setMode();
|
||||
}}
|
||||
>
|
||||
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||
</IconButton>
|
||||
</IconButton> */}
|
||||
|
||||
{(user?.avatar || user?.name) && (
|
||||
<>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() => setShowChatbox(true)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<IoChatbubbleEllipsesOutline />
|
||||
</IconButton>
|
||||
<ButtonBase
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
borderRadius: "30px",
|
||||
padding: "6px 12px",
|
||||
marginLeft: "8px",
|
||||
transition: "all 0.3s ease",
|
||||
border: "1px solid transparent",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.05)"
|
||||
: "rgba(0, 0, 0, 0.04)",
|
||||
transform: "translateY(-1px)",
|
||||
border: `1px solid ${mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.2)"
|
||||
: "rgba(0, 0, 0, 0.1)"
|
||||
}`,
|
||||
boxShadow:
|
||||
mode === "dark"
|
||||
? "0 4px 12px rgba(0,0,0,0.3)"
|
||||
: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
"&:active": {
|
||||
transform: "translateY(0px)",
|
||||
boxShadow: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap="16px"
|
||||
gap="12px"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
@@ -65,15 +155,80 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
xs: "none",
|
||||
sm: "inline-block",
|
||||
},
|
||||
fontWeight: 500,
|
||||
}}
|
||||
variant="subtitle2"
|
||||
>
|
||||
{user?.name}
|
||||
</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>
|
||||
</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>
|
||||
</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
|
||||
sx={{
|
||||
position: "absolute",
|
||||
@@ -34,100 +51,100 @@ export function MapSkeleton() {
|
||||
left: 20,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
gap: 1.5,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="rectangular"
|
||||
width={48}
|
||||
height={48}
|
||||
variant="circular"
|
||||
width={40}
|
||||
height={40}
|
||||
animation="wave"
|
||||
sx={{ borderRadius: 1 }}
|
||||
sx={{ boxShadow: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 右侧控制面板骨架 */}
|
||||
{/* 右侧控制面板骨架 (抽屉式) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 20,
|
||||
right: 20,
|
||||
width: 320,
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: { xs: "100%", sm: 360 },
|
||||
height: "100%",
|
||||
bgcolor: "background.paper",
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
boxShadow: 3,
|
||||
borderLeft: 1,
|
||||
borderColor: "divider",
|
||||
p: 3,
|
||||
zIndex: 5,
|
||||
display: { xs: "none", md: "flex" },
|
||||
flexDirection: "column",
|
||||
boxShadow: -2,
|
||||
}}
|
||||
>
|
||||
<Skeleton width="60%" height={32} animation="wave" sx={{ mb: 2 }} />
|
||||
<Skeleton width="100%" height={24} animation="wave" sx={{ mb: 1 }} />
|
||||
<Skeleton width="80%" height={24} animation="wave" sx={{ mb: 1 }} />
|
||||
<Skeleton width="90%" height={24} animation="wave" sx={{ mb: 2 }} />
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height={200}
|
||||
animation="wave"
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
<Skeleton variant="text" width="60%" height={40} sx={{ mb: 3 }} />
|
||||
|
||||
{/* 面板内容区块 */}
|
||||
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||||
<Skeleton variant="rectangular" width="100%" height={100} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="rectangular" width="100%" height={180} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Box key={i} sx={{ display: "flex", gap: 2, mb: 2 }}>
|
||||
<Skeleton variant="circular" width={36} height={36} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="80%" />
|
||||
<Skeleton variant="text" width="50%" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 底部时间轴骨架 */}
|
||||
{/* 底部时间轴/控制条骨架 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
bottom: 30,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "60%",
|
||||
width: { xs: "90%", md: "60%" },
|
||||
height: 64,
|
||||
bgcolor: "background.paper",
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
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
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 100,
|
||||
right: 20,
|
||||
bottom: 110,
|
||||
right: { xs: 20, md: 380 }, // Adjust if drawer is open
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
zIndex: 4,
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width={40}
|
||||
height={40}
|
||||
animation="wave"
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width={40}
|
||||
height={40}
|
||||
animation="wave"
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 比例尺骨架 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
}}
|
||||
>
|
||||
<Skeleton width={120} height={24} animation="wave" />
|
||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
|
||||
</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 "dayjs/locale/zh-cn"; // 引入中文包
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { useMap } from "@app/OlMap/MapComponent";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { Style, Stroke, Icon } from "ol/style";
|
||||
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||
import Feature, { FeatureLike } from "ol/Feature";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import axios from "axios";
|
||||
import { api } from "@/lib/api";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { along, lineString, length, toMercator } from "@turf/turf";
|
||||
import { Point } from "ol/geom";
|
||||
@@ -61,6 +61,39 @@ const AnalysisParameters: React.FC = () => {
|
||||
duration > 0 &&
|
||||
schemeName.trim() !== "";
|
||||
|
||||
// 地图点击选择要素事件处理函数
|
||||
const handleMapClickSelectFeatures = useCallback(
|
||||
async (event: { coordinate: number[] }) => {
|
||||
if (!map) return;
|
||||
const feature = await mapClickSelectFeatures(event, map);
|
||||
const layer = feature?.getId()?.toString().split(".")[0];
|
||||
|
||||
if (!feature) return;
|
||||
if (
|
||||
feature.getGeometry()?.getType() === "Point" ||
|
||||
(layer !== "geo_pipes_mat" && layer !== "geo_pipes")
|
||||
) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请选择线类型管道要素。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const featureId = feature.getProperties().id;
|
||||
setHighlightFeatures((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(f) => f.getProperties().id === featureId,
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
return prev.filter((_, i) => i !== existingIndex);
|
||||
} else {
|
||||
return [...prev, feature];
|
||||
}
|
||||
});
|
||||
},
|
||||
[map, open],
|
||||
);
|
||||
|
||||
// 初始化管道图层和高亮图层
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
@@ -137,7 +170,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
map.removeLayer(highlightLayer);
|
||||
map.un("click", handleMapClickSelectFeatures);
|
||||
};
|
||||
}, [map]);
|
||||
}, [map, handleMapClickSelectFeatures]);
|
||||
// 高亮要素的函数
|
||||
useEffect(() => {
|
||||
if (!highlightLayer) {
|
||||
@@ -155,7 +188,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
source.addFeature(feature);
|
||||
}
|
||||
});
|
||||
}, [highlightFeatures]);
|
||||
}, [highlightFeatures, highlightLayer]);
|
||||
|
||||
// 同步高亮要素和爆管点信息
|
||||
useEffect(() => {
|
||||
@@ -185,42 +218,6 @@ const AnalysisParameters: React.FC = () => {
|
||||
});
|
||||
}, [highlightFeatures]);
|
||||
|
||||
// 地图点击选择要素事件处理函数
|
||||
const handleMapClickSelectFeatures = useCallback(
|
||||
async (event: { coordinate: number[] }) => {
|
||||
if (!map) return;
|
||||
const feature = await mapClickSelectFeatures(event, map);
|
||||
const layer = feature?.getId()?.toString().split(".")[0];
|
||||
|
||||
if (!feature) return;
|
||||
if (
|
||||
feature.getGeometry()?.getType() === "Point" ||
|
||||
(layer !== "geo_pipes_mat" && layer !== "geo_pipes")
|
||||
) {
|
||||
// 点类型几何不处理
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请选择线类型管道要素。",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const featureId = feature.getProperties().id;
|
||||
setHighlightFeatures((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(f) => f.getProperties().id === featureId,
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
// 如果已存在,移除
|
||||
return prev.filter((_, i) => i !== existingIndex);
|
||||
} else {
|
||||
// 如果不存在,添加
|
||||
return [...prev, feature];
|
||||
}
|
||||
});
|
||||
},
|
||||
[map],
|
||||
);
|
||||
|
||||
// 开始选择管道
|
||||
const handleStartSelection = () => {
|
||||
if (!map) return;
|
||||
@@ -283,8 +280,11 @@ const AnalysisParameters: React.FC = () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await axios.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, {
|
||||
await api.get(`${config.BACKEND_URL}/api/v1/burst_analysis/`, {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
||||
},
|
||||
});
|
||||
// 更新弹窗为成功状态
|
||||
open?.({
|
||||
@@ -381,7 +381,7 @@ const AnalysisParameters: React.FC = () => {
|
||||
key={pipe.id}
|
||||
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}
|
||||
</Typography>
|
||||
<Typography className="flex-shrink-0 text-sm text-gray-600">
|
||||
+5
-84
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
@@ -22,13 +22,9 @@ import AnalysisParameters from "./AnalysisParameters";
|
||||
import SchemeQuery from "./SchemeQuery";
|
||||
import LocationResults from "./LocationResults";
|
||||
import ValveIsolation from "./ValveIsolation";
|
||||
import ContaminantAnalysisParameters from "../ContaminantSimulation/AnalysisParameters";
|
||||
import ContaminantSchemeQuery from "../ContaminantSimulation/SchemeQuery";
|
||||
import ContaminantResultsPanel from "../ContaminantSimulation/ResultsPanel";
|
||||
import axios from "axios";
|
||||
import { api } from "@/lib/api";
|
||||
import { config } from "@config/config";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { useData } from "@app/OlMap/MapComponent";
|
||||
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
|
||||
|
||||
interface TabPanelProps {
|
||||
@@ -56,29 +52,17 @@ interface BurstPipeAnalysisPanelProps {
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
type PanelMode = "burst" | "contaminant";
|
||||
|
||||
const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
open: controlledOpen,
|
||||
onToggle,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(true);
|
||||
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 [locationResults, setLocationResults] = useState<LocationResult[]>([]);
|
||||
// 选中的管段ID数组
|
||||
const [selectedPipeIds, setSelectedPipeIds] = useState<string[]>([]);
|
||||
// 关阀分析状态提升到父组件
|
||||
const [valveAnalysisTriggered, setValveAnalysisTriggered] = useState(false);
|
||||
// 关阀分析结果和加载状态
|
||||
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
||||
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
||||
@@ -99,19 +83,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
setCurrentTab(newValue);
|
||||
};
|
||||
|
||||
const handleModeChange = (_event: React.SyntheticEvent, newMode: PanelMode) => {
|
||||
setPanelMode(newMode);
|
||||
// 切换模式时,如果当前标签索引超出新模式的标签数量,重置为第一个标签
|
||||
// 爆管分析有4个标签(0-3),水质模拟有3个标签(0-2)
|
||||
const maxTabIndex = newMode === "burst" ? 3 : 2;
|
||||
if (currentTab > maxTabIndex) {
|
||||
setCurrentTab(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocateScheme = async (scheme: SchemeRecord) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
|
||||
);
|
||||
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 isBurstMode = panelMode === "burst";
|
||||
const panelTitle = isBurstMode ? "爆管分析" : "水质模拟";
|
||||
const panelTitle = "爆管分析";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -210,32 +177,6 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
</Tooltip>
|
||||
</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">
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
@@ -271,63 +212,43 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
||||
<Tab
|
||||
icon={<MyLocationIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label={isBurstMode ? "定位结果" : "模拟结果"}
|
||||
label="定位结果"
|
||||
/>
|
||||
{isBurstMode && (
|
||||
<Tab
|
||||
icon={<HandymanIcon fontSize="small" />}
|
||||
iconPosition="start"
|
||||
label="关阀分析"
|
||||
/>
|
||||
)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<TabPanel value={currentTab} index={0}>
|
||||
{isBurstMode ? (
|
||||
<AnalysisParameters />
|
||||
) : (
|
||||
<ContaminantAnalysisParameters />
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={currentTab} index={1}>
|
||||
{isBurstMode ? (
|
||||
<SchemeQuery
|
||||
schemes={schemes}
|
||||
onSchemesChange={setSchemes}
|
||||
onLocate={handleLocateScheme}
|
||||
/>
|
||||
) : (
|
||||
<ContaminantSchemeQuery onViewResults={() => setCurrentTab(2)} />
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={currentTab} index={2}>
|
||||
{isBurstMode ? (
|
||||
<LocationResults
|
||||
results={locationResults}
|
||||
onAnalyze={handleAnalyzePipe}
|
||||
/>
|
||||
) : (
|
||||
<ContaminantResultsPanel schemeName={data?.schemeName} />
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{isBurstMode && (
|
||||
<TabPanel value={currentTab} index={3}>
|
||||
<ValveIsolation
|
||||
initialPipeIds={selectedPipeIds}
|
||||
shouldFetch={valveAnalysisTriggered}
|
||||
onFetchComplete={() => setValveAnalysisTriggered(false)}
|
||||
loading={valveAnalysisLoading}
|
||||
result={valveAnalysisResult}
|
||||
onLoadingChange={setValveAnalysisLoading}
|
||||
onResultChange={setValveAnalysisResult}
|
||||
/>
|
||||
</TabPanel>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
</>
|
||||
+9
-50
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
} from "@mui/material";
|
||||
import {
|
||||
LocationOn as LocationIcon,
|
||||
Handyman as HandymanIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { useMap } from "@app/OlMap/MapComponent";
|
||||
import { useMap } from "@components/olmap/core/MapComponent";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
@@ -33,18 +32,16 @@ import { toLonLat } from "ol/proj";
|
||||
import moment from "moment";
|
||||
import "moment-timezone";
|
||||
import { LocationResult } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
||||
|
||||
interface LocationResultsProps {
|
||||
results?: LocationResult[];
|
||||
onAnalyze?: (pipeIds: string[]) => void;
|
||||
}
|
||||
|
||||
const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
results = [],
|
||||
onAnalyze,
|
||||
}) => {
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
useState<VectorLayer<VectorSource> | null>(null);
|
||||
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
const map = useMap();
|
||||
|
||||
@@ -147,19 +144,17 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
});
|
||||
|
||||
map.addLayer(highlightLayer);
|
||||
setHighlightLayer(highlightLayer);
|
||||
highlightLayerRef.current = highlightLayer;
|
||||
|
||||
return () => {
|
||||
highlightLayerRef.current = null;
|
||||
map.removeLayer(highlightLayer);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// 高亮要素的函数
|
||||
useEffect(() => {
|
||||
if (!highlightLayer) {
|
||||
return;
|
||||
}
|
||||
const source = highlightLayer.getSource();
|
||||
const source = highlightLayerRef.current?.getSource();
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +166,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
source.addFeature(feature);
|
||||
}
|
||||
});
|
||||
}, [highlightFeatures, highlightLayer]);
|
||||
}, [highlightFeatures]);
|
||||
|
||||
// 取第一条记录或空对象
|
||||
const result = results.length > 0 ? results[0] : null;
|
||||
@@ -309,7 +304,7 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
sx={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
{result.leakage !== null
|
||||
? `${result.leakage.toFixed(2)} m³/h`
|
||||
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -349,23 +344,6 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
管段列表
|
||||
</Typography>
|
||||
<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="定位所有管道">
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -404,25 +382,6 @@ const LocationResults: React.FC<LocationResultsProps> = ({
|
||||
{pipeId}
|
||||
</Typography>
|
||||
<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="定位管段">
|
||||
<IconButton
|
||||
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 "dayjs/locale/zh-cn"; // 引入中文包
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import axios from "axios";
|
||||
import { api } from "@/lib/api";
|
||||
import moment from "moment";
|
||||
import { config, NETWORK_NAME } from "@config/config";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import { useData, useMap } from "@app/OlMap/MapComponent";
|
||||
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from "@turf/turf";
|
||||
import { Point } from "ol/geom";
|
||||
import { toLonLat } from "ol/proj";
|
||||
import Timeline from "@app/OlMap/Controls/Timeline";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
import { SchemaItem, SchemeRecord } from "./types";
|
||||
|
||||
interface SchemeQueryProps {
|
||||
@@ -109,7 +109,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
|
||||
);
|
||||
let filteredResults = response.data;
|
||||
@@ -122,8 +122,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
setSchemes(
|
||||
filteredResults.map((item: SchemaItem) => ({
|
||||
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
||||
id: item.scheme_id,
|
||||
schemeName: item.scheme_name,
|
||||
type: item.scheme_type,
|
||||
@@ -131,8 +130,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
create_time: item.create_time,
|
||||
startTime: item.scheme_start_time,
|
||||
schemeDetail: item.scheme_detail,
|
||||
})),
|
||||
);
|
||||
}));
|
||||
setSchemes(nextSchemes);
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
open?.({
|
||||
@@ -299,7 +298,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
source.addFeature(feature);
|
||||
}
|
||||
});
|
||||
}, [highlightFeatures]);
|
||||
}, [highlightFeatures, highlightLayer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user