108 Commits

Author SHA1 Message Date
jiang a1442fc062 ci: retry registry pushes
Build Push and Deploy / docker-image (push) Failing after 3s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
Retry Docker image pushes to the Gitea registry so transient EOF failures during blob upload do not fail the whole CD run on the first attempt.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 16:07:59 +08:00
jiang 260c493fc8 ci: enable full cd path
Build Push and Deploy / docker-image (push) Failing after 1m36s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
Remove test-tag bypass logic so release tags exercise registry login, image push, and deploy webhook end to end on Gitea Actions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 16:05:08 +08:00
jiang 46a4d7157d ci: harden test tag guards
Build Push and Deploy / docker-image (push) Failing after 51s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
Use direct shell checks on github.ref_name inside workflow steps so test tags skip registry login, image push, and deploy webhook regardless of Gitea expression behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 16:01:08 +08:00
jiang 3ba252462d 更新 .gitignore,添加 memery.md 文件 2026-04-24 15:59:58 +08:00
jiang 5ca9a55a7b ci: skip deploy steps for test tags
Build Push and Deploy / docker-image (push) Failing after 50s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
Use workflow-level conditions for registry login and deploy webhook steps so *-test tags only validate the build path on Gitea Actions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 15:57:38 +08:00
jiang 9206c480b2 ci: lowercase image repository
Build Push and Deploy / docker-image (push) Failing after 1m59s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
Normalize github.repository to lowercase before composing Docker image tags so Gitea registry references stay valid on the runner.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 15:54:03 +08:00
jiang 23bd2f47c3 ci: use ubuntu-22.04 runner label
Build Push and Deploy / docker-image (push) Failing after 2s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
Switch runs-on to the short ubuntu-22.04 label so Gitea matches the online runner mapping to gitea/runner-images:ubuntu-22.04.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 15:50:39 +08:00
jiang c4269f40e3 ci: pin gitea runner image
Build Push and Deploy / docker-image (push) Has been cancelled
Build Push and Deploy / deploy-fallback-log (push) Has been cancelled
Use the full ubuntu runner label so Gitea Actions resolves gitea/runner-images:ubuntu-22.04 instead of falling back to ubuntu:latest during test runs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 15:43:36 +08:00
jiang 3afe885cc0 ci: harden gitea package workflow
Build Push and Deploy / docker-image (push) Failing after 2s
Build Push and Deploy / deploy-fallback-log (push) Successful in 2s
Make checkout idempotent for reused runner workspaces and add a safe test-tag path that validates builds without pushing images or calling the deploy webhook.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 15:31:01 +08:00
jiang b99fe66704 refactor: checkout no longer depends on node actions 2026-04-24 15:19:35 +08:00
jiang c2785f0746 chore: normalize registry host for docker image refs
Build Push and Deploy / docker-image (push) Failing after 1m9s
Build Push and Deploy / deploy-fallback-log (push) Successful in 2s
2026-04-24 15:15:03 +08:00
jiang 1ed09c9594 chore(workflow): use host docker instead of buildx
Build Push and Deploy / docker-image (push) Failing after 10s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-04-24 15:10:18 +08:00
jiang baa5d41bec 调整工作流环境,移除 Git 和 Docker 安装步骤
Build Push and Deploy / docker-image (push) Failing after 15s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 15:06:55 +08:00
jiang 05868c6af6 ci: bootstrap docker cli in runner
Build Push and Deploy / deploy-fallback-log (push) Has been cancelled
Build Push and Deploy / docker-image (push) Has been cancelled
2026-04-24 14:39:29 +08:00
jiang e81305d046 ci: use sh shell for gitea runner compatibility
Build Push and Deploy / docker-image (push) Failing after 1m3s
Build Push and Deploy / deploy-fallback-log (push) Failing after 2s
2026-04-24 14:36:38 +08:00
jiang b963562a5f ci: add git bootstrap for runner
Build Push and Deploy / docker-image (push) Failing after 7s
Build Push and Deploy / deploy-fallback-log (push) Failing after 0s
2026-04-24 14:34:42 +08:00
jiang bfd41b58e3 ci: fix checkout server url for gitea
Build Push and Deploy / docker-image (push) Failing after 3m49s
Build Push and Deploy / deploy-fallback-log (push) Failing after 1s
2026-04-24 14:25:10 +08:00
jiang 333d0d3353 更新依赖版本,简化工作流配置 2026-04-24 14:23:48 +08:00
jiang f207e2b192 统一方案类型命名为小写形式 2026-04-24 09:19:42 +08:00
jiang 4f195b0e06 ci: pin action versions for gitea runner
Build Push and Deploy / docker-image (push) Failing after 49s
Build Push and Deploy / deploy-fallback-log (push) Failing after 1s
2026-04-23 17:53:17 +08:00
jiang 0f110ce0c6 ci: run gitea workflow on node runner
Build Push and Deploy / docker-image (push) Failing after 8m55s
Build Push and Deploy / deploy-fallback-log (push) Failing after 0s
2026-04-23 17:50:09 +08:00
jiang a23626614f ci: run gitea job in node container 2026-04-23 17:48:52 +08:00
jiang 1debaed7ea 更新 Checkout 步骤,添加 GitHub 服务器 URL 配置,使用 Gitea 服务器
Build Push and Deploy / docker-image (push) Failing after 2m54s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 17:40:44 +08:00
jiang 74b4a4157c 区分 secrets
Build Push and Deploy / deploy-fallback-log (push) Has been cancelled
Build Push and Deploy / docker-image (push) Has been cancelled
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 15:18:50 +08:00
jiang efd04fd651 移除 API URL 环境变量配置
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 11:57:57 +08:00
jiang 5aa28c8409 新增 API URL 配置,更新 Dockerfile 和 docker-compose
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 11:52:49 +08:00
jiang 8b6dda08e6 新增 gitea 工作流
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 11:43:37 +08:00
jiang 427cbe70b3 更新音频服务 URL 为正式环境地址 2026-04-23 11:25:08 +08:00
jiang 6410df0cb7 添加方案记录缓存支持到爆管和漏损检测面板 2026-04-15 18:42:50 +08:00
jiang ff5cbfde9c 更新时间格式为 ISO 格式并修正 API 路径 2026-04-15 17:40:52 +08:00
jiang 5cbf1e82f8 更新爆管定位和爆管侦测的顺序 2026-04-15 17:40:45 +08:00
jiang 259202ca8f 添加音频服务 URL 配置到环境变量;使用新的 TTS 服务 2026-04-15 17:40:30 +08:00
jiang bfa4020239 更新 TypeScript 配置,目标版本改为 esnext 2026-04-15 11:52:40 +08:00
jiang 5dab6464c3 更新 React 和 React-DOM 版本至 19.2.4 2026-04-07 10:08:00 +08:00
jiang b752be498a 更新 @refinedev 相关依赖版本至最新 2026-04-07 09:46:22 +08:00
jiang 781711943a 添加 NEXT_PUBLIC_COPILOT_URL 变量到环境配置 2026-04-07 09:45:15 +08:00
jiang 7d05ad4920 更新 @refinedev 相关依赖版本;修复漏洞 2026-04-07 09:45:06 +08:00
jiang f0fad61bb2 调整预设对话 2026-04-03 14:08:59 +08:00
jiang d763876f86 重构 GlobalChatbox 组件,拆分为多个模块 2026-04-03 14:07:27 +08:00
jiang 56b4777dbd 优化queryFeaturesByIds ID 处理逻辑,确保查询功能正常 2026-04-03 13:58:44 +08:00
jiang c484aad1d3 抽象统一定位方法,支持多种地理要素 2026-04-03 13:45:37 +08:00
jiang d610a09c14 添加工具调用解析和聊天工具操作处理 2026-04-03 11:49:05 +08:00
jiang a1c8041b11 添加常用功能面板 2026-04-02 16:10:23 +08:00
jiang 295c959b52 添加语音识别和朗读功能 2026-04-02 15:24:05 +08:00
jiang adc12c13f9 添加聊天框可调整宽度功能,优化用户体验 2026-03-30 17:05:37 +08:00
jiang 6559d0c062 添加 Markdown 拓展支持 2026-03-30 17:03:59 +08:00
jiang a101e79750 添加聊天框消息解析功能;优化请求头处理;更新部分 api base url 2026-03-27 18:00:30 +08:00
jiang 8713e5a468 优化聊天框状态持久化,添加 Markdown 样式支持;调整地图组件的层级,避免和聊天框冲突 2026-03-26 11:55:19 +08:00
jiang 03a77f7368 优化聊天框状态持久化,增强错误处理逻辑;优化信息可读性 2026-03-24 16:44:19 +08:00
jiang 825acbf29c 优化聊天框输入聚焦逻辑,增强网络错误处理 2026-03-24 16:25:09 +08:00
jiang 045391d036 更新依赖,优化认证流程;添加聊天框动画效果,优化消息处理逻辑 2026-03-24 10:56:25 +08:00
jiang accf6ad254 添加全局 Copilot 聊天框组件 2026-03-23 18:03:24 +08:00
jiang 55362bef8f 去掉全局 id="deck-canvas" 路径,改为实例级 canvasRef,修复可能出现的 Uncaught Error: deck.gl: assertion failed 的问题 2026-03-19 15:38:45 +08:00
jiang d232104aa4 优化项目配置逻辑,增强错误处理和状态更新 2026-03-17 18:42:11 +08:00
jiang e1e4664dec 移除优化分区页面 2026-03-17 10:41:46 +08:00
jiang e0ab4bf60d 调整打包命令缩进格式 2026-03-13 17:52:45 +08:00
JIANG abfc8770a4 优化源代码打包步骤,调整排除规则 2026-03-13 17:39:41 +08:00
JIANG 71be47b956 优化构建工作流格式,统一引号风格 2026-03-13 17:37:43 +08:00
JIANG 081e4c4c13 新增构建和打包工作流 2026-03-13 17:36:42 +08:00
JIANG a7106a7289 隐藏侦测结果部分内容 2026-03-12 18:43:42 +08:00
JIANG 76aa28c701 解决重复通知 key 的问题 2026-03-12 11:40:37 +08:00
JIANG a7f4867afe 生成agent instructions 2026-03-11 17:50:03 +08:00
JIANG e2ea1853f1 新增爆管侦测面板及相关功能模块 2026-03-11 16:40:09 +08:00
JIANG f0f9d3f4f9 修复 lint warnings 2026-03-10 18:15:11 +08:00
JIANG 73201ae44e 修复lint errors 2026-03-10 17:52:00 +08:00
JIANG 62914f80c3 修复 redo undo 的逻辑错误 2026-03-10 17:35:20 +08:00
JIANG 64dcf9cbdb 更新 ESLint 配置,修改 lint 脚本命令 2026-03-10 11:38:37 +08:00
JIANG 520e1cb3f1 前端项目结构调整 2026-03-10 11:04:30 +08:00
JIANG 7f25bd34d5 后端获取的数据转换漏损量单位为 m³/h,优化数据展示 2026-03-07 19:56:35 +08:00
JIANG 47e47fc605 转换实际需水量单位为 m³/h,优化数据展示 2026-03-07 17:49:14 +08:00
JIANG b4ab3e287b 重构单位导入路径,优化代码结构 2026-03-07 17:31:14 +08:00
JIANG ddb02cc688 统一流量单位为 m³/h,优化相关组件 2026-03-07 17:21:01 +08:00
JIANG 6b68b7d081 移除正常时间参数,简化分析参数逻辑 2026-03-07 14:25:31 +08:00
JIANG 2f24ab5d66 更新爆管定位功能,优化数据处理和展示 2026-03-07 13:54:15 +08:00
JIANG 133880f7fc 删除提示 2026-03-07 11:47:27 +08:00
JIANG 5ed6740a24 添加爆管定位功能及相关组件 2026-03-07 10:50:07 +08:00
JIANG 9beba1cf6f 更新遗传算法默认参数;更新漏损流量单位为m3/h 2026-03-06 14:13:50 +08:00
JIANG bf6edf2662 完成节点样式变更 2026-03-06 14:09:26 +08:00
JIANG 5430a9d885 分离识别结果标签页;限制 DMA 数量最大数量 2026-03-06 10:15:47 +08:00
JIANG 377fc32f4c 实现DMA漏损识别面板整体设计 2026-03-06 09:59:06 +08:00
JIANG b73481d604 更新管道冲洗面板的样式设计 2026-03-05 11:33:09 +08:00
JIANG cd34e511ac 未认证时进入登录页面 2026-03-02 11:34:07 +08:00
JIANG 6c5862f7e4 修复react内容报错 2026-03-02 11:33:37 +08:00
JIANG 2d27e803a3 新增请求未认证时,触发登陆状态变更操作 2026-02-27 17:19:41 +08:00
JIANG f9dc4b74d0 变更初始项目信息 2026-02-27 17:18:33 +08:00
JIANG 66f2390078 暂存 2026-02-11 18:58:10 +08:00
JIANG 9d06226cb4 Implemented a Zustand-based project_id store, expanded project selection/switching to persist project_id,
and centralized backend requests via api/apiFetch (including data provider updates) to inject X-Project-ID.
2026-02-11 16:29:18 +08:00
JIANG a2e6c1f416 修复MAP_EXTENT状态更新的BUG 2026-02-11 14:17:16 +08:00
JIANG 2911b87fac 提升extent变量状态;修改部分默认值 2026-02-11 13:53:56 +08:00
JIANG 8b6198a2ac 调整环境变量参数,支持项目切换 2026-02-11 12:07:29 +08:00
JIANG 03e5f1456c 完善比例尺控件,调整控件位置 2026-02-11 11:52:06 +08:00
JIANG 25bde02b43 为登录后的页面新增切换项目弹窗 2026-02-10 17:11:04 +08:00
JIANG 1e8af75b88 新增项目选择弹窗(预设选项),支持变更环境变量 2026-02-10 16:13:04 +08:00
JIANG 8ea70d04ad 修改环境变量 2026-02-10 15:23:23 +08:00
JIANG 1d15eeb172 水质模拟默认设置pattern修改为CONSTANT 2026-02-10 15:23:14 +08:00
JIANG ae1f9b284f 调整环境变量配置,便于docker打包 2026-02-09 15:32:35 +08:00
JIANG 409057cef2 支持管道冲洗模块流量参数为0 2026-02-09 15:32:10 +08:00
JIANG 2c51785157 修改管道清洗默认值 2026-02-06 17:47:55 +08:00
JIANG 6be4a0de14 修改爆管分析传递的参数格式 2026-02-06 16:59:59 +08:00
JIANG 9d12b1960c 修复scheme计算属性无法显示的问题 2026-02-06 11:32:50 +08:00
JIANG cbfce9164e 调整工具栏,新增schemeType查询 2026-02-05 18:32:14 +08:00
JIANG 62a97459d0 修改管道冲洗点击提示信息 2026-02-05 17:39:49 +08:00
JIANG 4fbe845015 完成管道冲洗功能页面;调整水质模拟默认pattern;调整sidebar菜单名; 2026-02-05 17:38:23 +08:00
JIANG f89e43eee2 新增管道冲洗页面 2026-02-05 11:59:23 +08:00
JIANG 4bd7b48bcf 修改项目描述 2026-02-05 11:56:54 +08:00
JIANG 5b52afcc53 爆管分析、水质模拟模块分离;调整sidebar 2026-02-05 11:56:42 +08:00
JIANG 9bb0f8dcd7 重新设计关阀分析模块 2026-02-05 10:50:15 +08:00
JIANG bc73db66de 升级nextjs,修复部分依赖 2026-02-04 15:37:08 +08:00
126 changed files with 16713 additions and 3630 deletions
+9 -6
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
KEYCLOAK_CLIENT_ID="tjwater"
KEYCLOAK_CLIENT_SECRET="83h0n413hau9bldzWdEaq6xRfASv24s5"
KEYCLOAK_ISSUER="https://keycloak.waternetwork.cn/realms/tjwater"
NEXTAUTH_SECRET="eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiS"
NEXTAUTH_URL="https://demo.waternetwork.cn/"
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn"
NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25"
NEXT_PUBLIC_NETWORK_NAME="tjwater"
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"
NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec"
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
+130
View File
@@ -0,0 +1,130 @@
name: Build Push and Deploy
on:
push:
tags:
- "v*"
jobs:
docker-image:
runs-on: ubuntu-22.04
permissions:
contents: read
defaults:
run:
shell: sh
steps:
- name: Checkout code
env:
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
GIT_USERNAME: ${{ github.actor }}
GIT_TOKEN: ${{ github.token }}
run: |
case "$SERVER_URL" in
http://*)
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
;;
https://*)
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
;;
*)
AUTH_SERVER_URL="$SERVER_URL"
;;
esac
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
else
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
fi
git fetch --depth=1 origin "$COMMIT_SHA"
git checkout --force --detach FETCH_HEAD
git clean -ffdx
- name: Normalize image metadata
env:
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
RAW_REPOSITORY: ${{ github.repository }}
IMAGE_TAG: ${{ github.ref_name }}
run: |
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
} >> "$GITHUB_ENV"
- name: Login to Gitea Container Registry
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_COPILOT_URL="${{ vars.NEXT_PUBLIC_COPILOT_URL }}" \
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
--build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \
--build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \
--build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \
--build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \
.
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest"
- name: Notify Deploy Server
run: |
curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
-d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
deploy-fallback-log:
runs-on: ubuntu-22.04
needs: docker-image
if: failure()
steps:
- name: Deployment not triggered
run: echo "Image build/push failed, deployment webhook was not called."
+60
View File
@@ -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.
+2 -1
View File
@@ -33,4 +33,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next-env.d.ts
memery.md
+16 -4
View File
@@ -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_COPILOT_URL
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
ARG NEXT_PUBLIC_MAP_URL
ARG NEXT_PUBLIC_MAP_WORKSPACE
ARG NEXT_PUBLIC_MAP_EXTENT
ARG NEXT_PUBLIC_NETWORK_NAME
ARG NEXT_PUBLIC_MAPBOX_TOKEN
ARG NEXT_PUBLIC_TIANDITU_TOKEN
COPY --from=deps /app/refine/node_modules ./node_modules
COPY . .
@@ -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"]
+33
View File
@@ -0,0 +1,33 @@
version: "3.9"
services:
frontend:
image: ${IMAGE_NAME:-refinedev/tjwater-frontend:latest}
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
NEXT_PUBLIC_COPILOT_URL: ${NEXT_PUBLIC_COPILOT_URL}
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
env_file:
- .env
environment:
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL}
NODE_ENV: production
HOSTNAME: 0.0.0.0
PORT: 3000
ports:
- "3000:3000"
restart: unless-stopped
pull_policy: always
+5
View File
@@ -0,0 +1,5 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
const config = [...nextCoreWebVitals];
export default config;
+22
View File
@@ -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.
+16
View File
@@ -1,6 +1,22 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
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$/,
+3060 -893
View File
File diff suppressed because it is too large Load Diff
+23 -11
View File
@@ -9,7 +9,7 @@
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
"build": "refine build",
"start": "refine start",
"lint": "next lint",
"lint": "eslint .",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
@@ -24,12 +24,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 +37,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 +73,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",
-58
View File
@@ -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"
}
}
+3 -3
View File
@@ -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,16 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="burst_analysis" />
<BurstPipeAnalysisPanel />
</MapComponent>
</div>
);
}
@@ -0,0 +1,5 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -0,0 +1,16 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="contaminant_analysis" />
<WaterQualityPanel />
</MapComponent>
</div>
);
}
@@ -0,0 +1,5 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -0,0 +1,20 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="dma_leak_identification"
hiddenButtons={["style"]}
/>
<DMALeakDetectionPanel />
</MapComponent>
</div>
);
}
@@ -0,0 +1,5 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -0,0 +1,16 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
<FlushingAnalysisPanel />
</MapComponent>
</div>
);
}
+14 -17
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import React, { Suspense } from "react";
import { RefineContext } from "../_refine_context";
import authOptions from "@app/api/auth/[...nextauth]/options";
import { Header } from "@components/header";
@@ -33,22 +32,20 @@ export default async function MainLayout({
}
return (
<RefineContext defaultMode={defaultMode}>
<ThemedLayout
Header={Header}
Title={Title}
childrenBoxProps={{
sx: { height: "100vh", p: 0 },
}}
containerBoxProps={{
sx: { height: "100%" },
}}
>
<Suspense fallback={<MapSkeleton />}>
{children}
</Suspense>
</ThemedLayout>
</RefineContext>
<ThemedLayout
Header={Header}
Title={Title}
childrenBoxProps={{
sx: { height: "100vh", p: 0 },
}}
containerBoxProps={{
sx: { height: "100%" },
}}
>
<Suspense fallback={<MapSkeleton />}>
{children}
</Suspense>
</ThemedLayout>
);
}
@@ -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>
);
}
+5 -5
View File
@@ -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>
);
}
+4 -4
View File
@@ -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[]>([]);
-180
View File
@@ -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;
-47
View File
@@ -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;
+71 -13
View File
@@ -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>
<App {...props} />
<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: "管道冲洗",
},
},
]}
+88 -4
View File
@@ -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;
+8 -9
View File
@@ -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>
+178
View File
@@ -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>
);
};
+522
View File
@@ -0,0 +1,522 @@
"use client";
import React, { useCallback, useState } from "react";
import {
Box,
Button,
Chip,
Paper,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
import TimelineRounded from "@mui/icons-material/TimelineRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
import type { ToolCall } from "./chatMessageSections";
/* ------------------------------------------------------------------ */
/* Interactive card rendered inside a chat bubble for tool actions */
/* (locate nodes/pipes, open history/SCADA panels). */
/* ------------------------------------------------------------------ */
type ToolMeta = {
label: string;
icon: React.ReactNode;
actionLabel: string;
color: string;
};
const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
locate_features: "",
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks",
};
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
const TOOL_META: Record<string, ToolMeta> = {
locate_features: {
label: "定位要素",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#5470c6",
},
locate_junctions: {
label: "定位节点",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#5470c6",
},
locate_pipes: {
label: "定位管道",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#91cc75",
},
locate_valves: {
label: "定位阀门",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#9a60b4",
},
locate_reservoirs: {
label: "定位水源",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#ea7ccc",
},
locate_pumps: {
label: "定位泵站",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#fc8452",
},
locate_tanks: {
label: "定位水池",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#3ba272",
},
view_history: {
label: "查看计算结果",
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
actionLabel: "查看曲线",
color: "#fac858",
},
view_scada: {
label: "查看监测数据",
icon: <SensorsRounded sx={{ fontSize: 18 }} />,
actionLabel: "查看数据",
color: "#ee6666",
},
show_chart: {
label: "显示图表",
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
actionLabel: "显示",
color: "#73c0de",
},
};
/* ---------- helpers ---------- */
function getToolDescription(toolCall: ToolCall): string {
const { params } = toolCall;
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
const resolveTimeRange = () => ({
startTime:
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined),
endTime:
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined),
});
const resolveLocateFeatureType = (): string => {
const rawType = params.feature_type;
if (typeof rawType === "string" && rawType.trim()) {
return rawType.trim().toLowerCase();
}
return "";
};
switch (toolCall.tool) {
case "locate_features":
case "locate_junctions":
case "locate_pipes":
case "locate_valves":
case "locate_reservoirs":
case "locate_pumps":
case "locate_tanks": {
const ids = normalizeIds();
const idsText =
ids.length > 3
? `${ids.slice(0, 3).join(", ")}${ids.length}`
: ids.join(", ");
if (toolCall.tool !== "locate_features") {
return idsText;
}
const featureType = resolveLocateFeatureType();
if (!featureType) {
return idsText;
}
return idsText
? `${featureType} · ${idsText}`
: featureType;
}
case "view_history":
case "view_scada": {
const infos =
toolCall.tool === "view_scada"
? resolveScadaFeatureInfos()
: ((params.feature_infos as [string, string][] | undefined) ?? []);
const names = infos.map(([id]) => id);
const base =
names.length > 3
? `${names.slice(0, 3).join(", ")}${names.length}`
: names.join(", ");
const { startTime, endTime } = resolveTimeRange();
if (!startTime && !endTime) {
return base;
}
const rangeLabel = `时间段: ${startTime ?? "--"} ~ ${endTime ?? "--"}`;
return base ? `${base} · ${rangeLabel}` : rangeLabel;
}
case "show_chart": {
return (params.title as string | undefined) ?? "数据图表";
}
default:
return "";
}
}
function buildAction(toolCall: ToolCall): ChatToolAction | null {
const { params } = toolCall;
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
const resolveTimeRange = () => ({
startTime:
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined),
endTime:
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined),
});
switch (toolCall.tool) {
case "locate_features": {
const featureTypeRaw = params.feature_type;
const featureType =
typeof featureTypeRaw === "string"
? featureTypeRaw.trim().toLowerCase()
: "";
const config = locateFeatureTypeToConfig(featureType);
if (!config) return null;
return {
type: "locate_features",
ids: normalizeIds(),
layer: config.layer,
geometryKind: config.geometryKind,
};
}
case "locate_junctions":
case "locate_pipes":
case "locate_valves":
case "locate_reservoirs":
case "locate_pumps":
case "locate_tanks": {
const layer = LOCATE_TOOL_TO_LAYER[toolCall.tool];
if (!layer) return null;
return {
type: "locate_features",
ids: normalizeIds(),
layer,
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
};
}
case "view_history": {
const historyRange = resolveTimeRange();
return {
type: "view_history",
featureInfos:
(params.feature_infos as [string, string][] | undefined) ?? [],
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime: historyRange.startTime,
endTime: historyRange.endTime,
};
}
case "view_scada": {
const scadaRange = resolveTimeRange();
return {
type: "view_scada",
featureInfos: resolveScadaFeatureInfos(),
startTime: scadaRange.startTime,
endTime: scadaRange.endTime,
};
}
case "show_chart":
return {
type: "show_chart",
title: params.title as string | undefined,
chartType:
(params.chart_type as "line" | "bar" | "pie" | undefined) ?? "line",
xData: (params.x_data as string[] | undefined) ?? [],
series:
(params.series as
| Array<{ name: string; data: number[]; type?: "line" | "bar" }>
| undefined) ?? [],
xAxisName: params.x_axis_name as string | undefined,
yAxisName: params.y_axis_name as string | undefined,
};
default:
return null;
}
}
/* ---------- component ---------- */
export interface ChatToolCallBlockProps {
toolCall: ToolCall;
}
export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
toolCall,
}) => {
const theme = useTheme();
const dispatch = useChatToolStore((s) => s.dispatch);
const [executed, setExecuted] = useState(false);
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
label: toolCall.tool,
icon: null,
actionLabel: "执行",
color: theme.palette.primary.main,
};
const description = getToolDescription(toolCall);
const handleExecute = useCallback(() => {
const action = buildAction(toolCall);
if (action) {
dispatch(action);
setExecuted(true);
}
}, [toolCall, dispatch]);
return (
<Paper
elevation={0}
sx={{
mt: 1.5,
mb: 1,
p: 1.5,
borderRadius: 3,
border: `1px solid ${alpha(meta.color, 0.25)}`,
bgcolor: alpha(meta.color, 0.04),
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
{/* Icon */}
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: alpha(meta.color, 0.12),
display: "flex",
alignItems: "center",
justifyContent: "center",
color: meta.color,
flexShrink: 0,
}}
>
{meta.icon}
</Box>
{/* Description */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "text.primary",
display: "block",
}}
>
{meta.label}
</Typography>
{description && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
fontSize: "0.75rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{description}
</Typography>
)}
</Box>
{/* Action */}
{executed ? (
<Chip
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
label="已执行"
size="small"
sx={{
bgcolor: alpha("#4caf50", 0.1),
color: "#4caf50",
fontWeight: 600,
fontSize: "0.75rem",
}}
/>
) : (
<Button
size="small"
variant="outlined"
onClick={handleExecute}
sx={{
borderColor: alpha(meta.color, 0.4),
color: meta.color,
fontWeight: 600,
fontSize: "0.75rem",
borderRadius: 2,
textTransform: "none",
whiteSpace: "nowrap",
"&:hover": {
borderColor: meta.color,
bgcolor: alpha(meta.color, 0.08),
},
}}
>
{meta.actionLabel}
</Button>
)}
</Stack>
</Paper>
);
};
const locateFeatureTypeToConfig = (
featureType: string,
): { layer: string; geometryKind: "point" | "line" } | null => {
switch (featureType) {
case "junction":
case "junctions":
return { layer: "geo_junctions_mat", geometryKind: "point" };
case "pipe":
case "pipes":
return { layer: "geo_pipes_mat", geometryKind: "line" };
case "valve":
case "valves":
return { layer: "geo_valves", geometryKind: "point" };
case "reservoir":
case "reservoirs":
return { layer: "geo_reservoirs", geometryKind: "point" };
case "pump":
case "pumps":
return { layer: "geo_pumps", geometryKind: "point" };
case "tank":
case "tanks":
return { layer: "geo_tanks", geometryKind: "point" };
default:
return null;
}
};
+426
View File
@@ -0,0 +1,426 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Paper,
Stack,
Typography,
alpha,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import { ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
export const TypingIndicator = () => {
return (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ y: 0 }}
animate={{ y: [-4, 4, -4] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
}}
/>
</motion.div>
))}
</Stack>
);
};
export const Blob = ({
color,
size,
top,
left,
delay,
}: {
color: string;
size: number;
top: string;
left: string;
delay: number;
}) => (
<motion.div
initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }}
animate={{
scale: [0.8, 1.2, 0.8],
opacity: [0.3, 0.5, 0.3],
x: [0, 30, 0],
y: [0, -30, 0],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay,
}}
style={{
position: "absolute",
top,
left,
width: size,
height: size,
borderRadius: "50%",
background: color,
filter: "blur(60px)",
zIndex: 0,
pointerEvents: "none",
}}
/>
);
type ChatMessageItemProps = {
message: Message;
theme: Theme;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
};
export const ChatMessageItem = React.memo(
({
message,
theme,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
sseChartParams,
}: ChatMessageItemProps) => {
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const parsedAssistantSections =
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
return (
<motion.div
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "85%",
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
gap: 12,
alignItems: "flex-end",
}}
>
{!isUser && (
<Avatar
sx={{
width: 28,
height: 28,
bgcolor: isErrorMessage
? alpha(theme.palette.error.main, 0.12)
: alpha(theme.palette.secondary.main, 0.1),
mb: 0.5,
}}
>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
)}
</Avatar>
)}
<Box>
<Paper
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
sx={{
p: 2.5,
borderRadius: 4,
borderBottomRightRadius: isUser ? 4 : 24,
borderBottomLeftRadius: !isUser ? 4 : 24,
bgcolor: isUser
? "primary.main"
: isErrorMessage
? alpha(theme.palette.error.light, 0.18)
: "#fff",
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
background: isUser
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
: isErrorMessage
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
: undefined,
border: isErrorMessage
? `1px solid ${alpha(theme.palette.error.main, 0.35)}`
: "none",
boxShadow: isUser
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
: isErrorMessage
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
"--chat-md-text": isUser
? alpha("#fff", 0.96)
: isErrorMessage
? theme.palette.error.dark
: "#1f2937",
"--chat-md-heading": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#111827",
"--chat-md-link": isUser
? "#E3F2FD"
: isErrorMessage
? theme.palette.error.main
: "#7C3AED",
"--chat-md-link-hover": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#6D28D9",
"--chat-md-inline-code-bg": isUser
? "rgba(255,255,255,0.2)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#EEF2FF",
"--chat-md-inline-code-border": isUser
? alpha("#fff", 0.16)
: isErrorMessage
? alpha(theme.palette.error.main, 0.25)
: "#CBD5E1",
"--chat-md-inline-code-text": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#334155",
"--chat-md-pre-bg": isUser
? "rgba(11, 18, 32, 0.56)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isUser
? alpha("#fff", 0.12)
: isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isUser
? "#F8FAFC"
: isErrorMessage
? theme.palette.error.dark
: "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: isUser
? alpha("#fff", 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isUser
? alpha("#fff", 0.08)
: isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isUser
? alpha("#fff", 0.9)
: isErrorMessage
? theme.palette.error.dark
: "#475569",
}}
>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<div key={segIdx} className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text || "..."}
</ReactMarkdown>
</div>
);
}
if (segment.type === "tool_call") {
if (segment.toolCall.tool === "chart") {
return (
<ChatInlineChart
key={segment.toolCall.id}
{...(segment.toolCall.params as Record<string, unknown>)}
/>
);
}
if (segment.toolCall.tool === "show_chart") {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={
(p.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<motion.div
key="tool-pending"
initial={{ opacity: 0 }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} />
<Typography variant="caption" color="text.secondary">
...
</Typography>
</motion.div>
);
}
return null;
})}
{sseChartParams?.map((chart, idx) => (
<ChatInlineChart
key={`sse-chart-${idx}`}
title={(chart.params.title as string) ?? undefined}
chart_type={
(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(chart.params.x_data as string[]) ?? []}
series={
(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
/>
))}
</Paper>
{!isUser && !isErrorMessage && isTtsSupported && (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
{messageSpeechState === "idle" && (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{
color: "text.secondary",
opacity: 0.6,
"&:hover": { opacity: 1 },
p: 0.5,
}}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
)}
{messageSpeechState === "playing" && (
<>
<IconButton
size="small"
onClick={onPause}
aria-label="暂停朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
{messageSpeechState === "paused" && (
<>
<IconButton
size="small"
onClick={onResume}
aria-label="继续朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
</Stack>
)}
</Box>
</motion.div>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
+943
View File
@@ -0,0 +1,943 @@
"use client";
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
// MUI
import {
Avatar,
Box,
Drawer,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Paper,
Stack,
TextField,
Typography,
useTheme,
alpha,
} from "@mui/material";
// Icons
import CloseRounded from "@mui/icons-material/CloseRounded";
import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
// Logic
import { streamCopilotChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
import {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types";
import {
CHAT_STORAGE_KEY,
PRESET_PROMPTS,
createId,
getInitialChatState,
normalizeThoughtTagToken,
} from "./GlobalChatbox.utils";
import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts";
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const initialChatStateRef = useRef<PersistedChatState | null>(null);
if (initialChatStateRef.current === null) {
initialChatStateRef.current = getInitialChatState();
}
const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [width, setWidth] = useState(480);
const [isResizing, setIsResizing] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(
initialChatStateRef.current.conversationId
);
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
// SSE tool_call → inline chart data (keyed by assistantMessageId)
const [sseCharts, setSseCharts] = useState<
Record<string, Array<{ tool: string; params: Record<string, unknown> }>>
>({});
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const theme = useTheme();
// --- Voice Features ---
const {
speechState,
speakingMessageId,
speak: handleSpeak,
pause: handlePauseSpeech,
resume: handleResumeSpeech,
stop: handleStopSpeech,
isSupported: isTtsSupported,
} = useSpeechSynthesis();
const handleSpeechResult = useCallback((text: string) => {
setInput((prev) => prev + text);
}, []);
const {
isListening,
start: startListening,
stop: stopListening,
isSupported: isSttSupported,
} = useSpeechRecognition(handleSpeechResult);
const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
const isHeaderMenuOpen = Boolean(headerMenuAnchorEl);
// Auto-scroll
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => {
inputRef.current?.focus();
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
const state: PersistedChatState = { messages, conversationId };
try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
}
}, [messages, conversationId]);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming) return;
stopListening();
const userId = createId();
const assistantId = createId();
setInput("");
setIsStreaming(true);
setMessages((prev) => [
...prev,
{ id: userId, role: "user", content: prompt },
{ id: assistantId, role: "assistant", content: "" },
]);
const controller = new AbortController();
abortRef.current = controller;
// Track SSE tool_call hashes to deduplicate against text-parsed tool_calls
const sseToolHashes = new Set<string>();
const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => {
const { tool, params } = event;
const hash = `${tool}:${JSON.stringify(params)}`;
sseToolHashes.add(hash);
const startTime =
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined);
const endTime =
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined);
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
// show_chart → store as inline chart for rendering
if (tool === "show_chart") {
setSseCharts((prev) => ({
...prev,
[assistantId]: [
...(prev[assistantId] ?? []),
{ tool, params },
],
}));
return;
}
// Other frontend tools → dispatch to chatToolStore immediately
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds
.map((id) => String(id).trim())
.filter(Boolean);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const buildLocateFeaturesAction = (
layer: string,
geometryKind: "point" | "line",
): ChatToolAction => ({
type: "locate_features" as const,
ids: normalizeIds(),
layer,
geometryKind,
});
const buildLocateByFeatureType = (): ChatToolAction | null => {
const rawType = params.feature_type;
const featureType =
typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
const featureTypeMap: Record<
string,
{ layer: string; geometryKind: "point" | "line" }
> = {
junction: { layer: "geo_junctions_mat", geometryKind: "point" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line" },
valve: { layer: "geo_valves", geometryKind: "point" },
valves: { layer: "geo_valves", geometryKind: "point" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point" },
pump: { layer: "geo_pumps", geometryKind: "point" },
pumps: { layer: "geo_pumps", geometryKind: "point" },
tank: { layer: "geo_tanks", geometryKind: "point" },
tanks: { layer: "geo_tanks", geometryKind: "point" },
};
const config = featureTypeMap[featureType];
if (!config) return null;
return buildLocateFeaturesAction(config.layer, config.geometryKind);
};
const actionMap: Record<string, () => ChatToolAction | null> = {
locate_features: buildLocateByFeatureType,
locate_pipes: () => buildLocateFeaturesAction("geo_pipes_mat", "line"),
locate_junctions: () =>
buildLocateFeaturesAction("geo_junctions_mat", "point"),
locate_valves: () => buildLocateFeaturesAction("geo_valves", "point"),
locate_reservoirs: () =>
buildLocateFeaturesAction("geo_reservoirs", "point"),
locate_pumps: () => buildLocateFeaturesAction("geo_pumps", "point"),
locate_tanks: () => buildLocateFeaturesAction("geo_tanks", "point"),
view_history: () => ({
type: "view_history" as const,
featureInfos: (params.feature_infos as [string, string][]) ?? [],
dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime",
startTime,
endTime,
}),
view_scada: () => ({
type: "view_scada" as const,
featureInfos: resolveScadaFeatureInfos(),
startTime,
endTime,
}),
};
const buildAction = actionMap[tool];
if (buildAction) {
const action = buildAction();
if (action) dispatchToolAction(action);
}
};
try {
await streamCopilotChat({
message: prompt,
conversationId,
signal: controller.signal,
onEvent: (event) => {
if (event.type === "token") {
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
const normalizedToken = normalizeThoughtTagToken(event.content);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: m.content + normalizedToken, isError: false }
: m
)
);
} else if (event.type === "done") {
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId && m.content.trim().length === 0
? {
...m,
content: "⚠️ **错误:** Copilot 未返回内容,请稍后重试。",
isError: true,
}
: m
)
);
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: m.content || `⚠️ **错误:** ${event.message}`,
isError: true,
}
: m
)
);
setIsStreaming(false);
} else if (event.type === "tool_call") {
handleSseToolCall(event);
}
},
});
} catch (error) {
if (abortRef.current?.signal.aborted) {
setMessages((prev) =>
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
);
return;
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
: m
)
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
},
[conversationId, isStreaming, stopListening, dispatchToolAction],
);
const handleSend = async () => {
const prompt = input.trim();
if (!prompt || isStreaming) return;
await sendPrompt(prompt);
};
const handleAbort = () => {
abortRef.current?.abort();
setIsStreaming(false);
};
const handlePresetPromptSelect = useCallback((prompt: string) => {
setInput(prompt);
setIsPresetPanelOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, []);
const handleHeaderMenuOpen = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
setHeaderMenuAnchorEl(event.currentTarget);
},
[],
);
const handleHeaderMenuClose = useCallback(() => {
setHeaderMenuAnchorEl(null);
}, []);
const handleNewConversation = useCallback(() => {
abortRef.current?.abort();
handleStopSpeech();
stopListening();
setMessages([]);
setConversationId(undefined);
setInput("");
setIsStreaming(false);
handleHeaderMenuClose();
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, [handleHeaderMenuClose, handleStopSpeech, stopListening]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const newWidth = window.innerWidth - e.clientX;
if (newWidth > 320 && newWidth < 1200) {
setWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing]);
const renderedMessages = useMemo(
() =>
messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
theme={theme}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={handleSpeak}
onPause={handlePauseSpeech}
onResume={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
sseChartParams={sseCharts[message.id]}
/>
)),
[messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts],
);
return (
<Drawer
anchor="right"
variant="persistent"
open={open}
onClose={onClose}
hideBackdrop
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }}
PaperProps={{
sx: {
width: { xs: "100%", sm: width },
background: "transparent",
boxShadow: "none",
overflow: "visible", // Changed from "hidden" to show resizer handle if needed, though handle is inside.
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", // Disable transition during resize
},
}}
>
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#fff", 0.75), // Light glass base
backdropFilter: "blur(30px)",
position: "relative",
}}
>
{/* Resize Handle */}
<Box
onMouseDown={handleMouseDown}
sx={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "6px",
cursor: "col-resize",
zIndex: 200,
"&:hover": {
bgcolor: alpha(theme.palette.primary.main, 0.2),
},
"&::after": {
content: '""',
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
width: "2px",
height: "40px",
bgcolor: alpha(theme.palette.divider, 0.4),
borderRadius: "1px",
}
}}
/>
{/* Ambient Blobs */}
<Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} />
<Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} />
<Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} />
{/* Header - Transparent & Floating */}
<Box
sx={{
p: 3,
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<motion.div
whileHover={{ rotate: 10, scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<IconButton
onClick={handleHeaderMenuOpen}
aria-label="打开聊天菜单"
aria-controls={isHeaderMenuOpen ? "global-chatbox-header-menu" : undefined}
aria-expanded={isHeaderMenuOpen ? "true" : undefined}
aria-haspopup="menu"
sx={{
p: 0,
borderRadius: "50%",
}}
>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`,
boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`,
width: 48,
height: 48,
}}
>
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} />
</Avatar>
<Box
sx={{
position: "absolute",
bottom: 2,
right: 2,
width: 12,
height: 12,
bgcolor: "success.main",
borderRadius: "50%",
border: "2px solid #fff",
boxShadow: "0 0 0 2px rgba(255,255,255,0.5)"
}}
/>
</Box>
</IconButton>
</motion.div>
<Box>
<Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}>
Copilot
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
AI
</Typography>
</Box>
</Stack>
<Menu
id="global-chatbox-header-menu"
anchorEl={headerMenuAnchorEl}
open={isHeaderMenuOpen}
onClose={handleHeaderMenuClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
elevation: 8,
sx: {
mt: 1,
minWidth: 180,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
backdropFilter: "blur(12px)",
bgcolor: alpha("#fff", 0.92),
boxShadow: `0 16px 40px -16px ${alpha(theme.palette.common.black, 0.28)}`,
},
},
}}
>
<MenuItem onClick={handleNewConversation}>
<ListItemIcon>
<AddCommentRounded fontSize="small" />
</ListItemIcon>
<ListItemText
primary="新建对话"
secondary="清空当前会话"
primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 600 } }}
secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }}
/>
</MenuItem>
</Menu>
<motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}>
<CloseRounded />
</IconButton>
</motion.div>
</Box>
{/* Messages - Bouncy List */}
<Box
sx={{
flex: 1,
overflowY: "auto",
px: 2.5,
py: 2,
display: "flex",
flexDirection: "column",
gap: 2.5,
zIndex: 5,
}}
>
<AnimatePresence initial={false}>
{messages.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%" }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 6,
bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
maxWidth: 320,
mx: "auto",
textAlign: "center",
backdropFilter: "blur(10px)",
}}
>
<motion.div
animate={{ y: [-5, 5, -5] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
>
<AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} />
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom>
👋
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
</Typography>
</Paper>
</motion.div>
)}
{renderedMessages}
</AnimatePresence>
{isStreaming && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 40 }}
>
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`
}}
>
<TypingIndicator />
</Paper>
</motion.div>
)}
<div ref={bottomRef} style={{ height: 1 }} />
</Box>
{/* Input Area - Floating Capsule */}
<Box sx={{ p: 3, zIndex: 10 }}>
<Box sx={{ mb: 1.25, display: "flex", justifyContent: "flex-end" }}>
<Box sx={{ position: "relative", width: "100%", maxWidth: 520, display: "flex", justifyContent: "flex-end" }}>
<AnimatePresence initial={false}>
{isPresetPanelOpen && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ position: "absolute", right: 0, bottom: "calc(100% + 10px)", width: "100%", zIndex: 3 }}
>
<Paper
elevation={12}
sx={{
p: 1.2,
borderRadius: 3,
bgcolor: alpha("#fff", 0.92),
backdropFilter: "blur(12px)",
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
boxShadow: `0 20px 48px -20px ${alpha(theme.palette.common.black, 0.3)}`,
}}
>
<Stack spacing={0.8}>
{PRESET_PROMPTS.map((prompt, index) => (
<Box
key={`preset-${index}`}
component="button"
type="button"
onClick={() => handlePresetPromptSelect(prompt)}
sx={{
textAlign: "left",
width: "100%",
px: 1.1,
py: 0.9,
borderRadius: 2,
border: `1px solid ${alpha(theme.palette.divider, 0.24)}`,
bgcolor: alpha("#fff", 0.72),
color: "text.secondary",
fontSize: "0.84rem",
lineHeight: 1.45,
cursor: "pointer",
transition: "all 0.18s ease",
"&:hover": {
borderColor: alpha(theme.palette.primary.main, 0.45),
color: "text.primary",
transform: "translateY(-1px)",
boxShadow: `0 8px 24px -16px ${alpha(theme.palette.primary.main, 0.6)}`,
},
}}
>
{prompt}
</Box>
))}
</Stack>
</Paper>
</motion.div>
)}
</AnimatePresence>
<motion.div whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }}>
<Paper
elevation={10}
sx={{
borderRadius: 99,
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(10px)",
boxShadow: `0 14px 40px -14px ${alpha(theme.palette.primary.main, 0.35)}`,
overflow: "hidden",
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ pl: 1.2, pr: 0.5, py: 0.5 }}>
<Avatar
sx={{
width: 28,
height: 28,
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`,
}}
>
<AutoAwesome sx={{ fontSize: 16, color: "#fff" }} />
</Avatar>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.2 }}>
</Typography>
<IconButton
size="small"
onClick={() => setIsPresetPanelOpen((prev) => !prev)}
aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"}
sx={{ color: "text.secondary" }}
>
{isPresetPanelOpen ? <KeyboardArrowDownRounded /> : <KeyboardArrowUpRounded />}
</IconButton>
</Stack>
</Paper>
</motion.div>
</Box>
</Box>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Stack
direction="row"
alignItems="center"
component={Paper}
elevation={12}
sx={{
p: "6px 8px",
borderRadius: 50, // Full capsule
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(10px)",
border: `1px solid ${alpha("#fff", 0.6)}`,
boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`,
transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: `0 16px 48px -8px ${alpha(theme.palette.primary.main, 0.25)}`,
}
}}
>
<TextField
inputRef={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder="输入消息给 Copilot..."
fullWidth
multiline
maxRows={3}
variant="standard"
InputProps={{
disableUnderline: true,
sx: { px: 2.5, py: 1.5, fontSize: "1rem" },
}}
/>
{isSttSupported && (
<Box sx={{ display: "flex", alignItems: "center", mr: 1 }}>
{isListening ? (
<motion.div
animate={{ scale: [1, 1.15, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={stopListening}
aria-label="停止语音输入"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.1),
width: 44,
height: 44,
"&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) },
}}
>
<MicRounded />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={startListening}
disabled={isStreaming}
aria-label="语音输入"
sx={{
color: "text.secondary",
width: 44,
height: 44,
"&:hover": { color: "primary.main" },
}}
>
<MicRounded />
</IconButton>
)}
</Box>
)}
<Box sx={{ pr: 0.5 }}>
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div
key="stop"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, rotate: 180 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
onClick={handleAbort}
sx={{
bgcolor: alpha(theme.palette.error.main, 0.1),
color: "error.main",
width: 44, height: 44,
"&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) }
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div
key="send"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
disabled={!canSend}
onClick={() => void handleSend()}
sx={{
bgcolor: canSend ? "primary.main" : "action.disabledBackground",
color: "#fff",
width: 44, height: 44,
transition: "background-color 0.2s",
"&:hover": {
bgcolor: "primary.dark",
boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.5)}`
}
}}
>
<SendRounded sx={{ ml: 0.5 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Box>
</Stack>
</motion.div>
</Box>
</Box>
</Drawer>
);
};
@@ -0,0 +1,18 @@
export type Message = {
id: string;
role: "user" | "assistant";
content: string;
isError?: boolean;
};
export type Props = {
open: boolean;
onClose: () => void;
};
export type SpeechState = "idle" | "playing" | "paused";
export type PersistedChatState = {
messages: Message[];
conversationId?: string;
};
@@ -0,0 +1,59 @@
import type { PersistedChatState } from "./GlobalChatbox.types";
export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1";
const THINK_TAG_ALIAS_PATTERN =
/<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi;
export const PRESET_PROMPTS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
];
export const normalizeThoughtTagToken = (token: string): string =>
token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) =>
closingSlash ? "</think>" : "<think>",
);
export const stripMarkdown = (md: string): string =>
md
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[.*?\]\(.*?\)/g, "")
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
.replace(/#{1,6}\s+/g, "")
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/~~(.+?)~~/g, "$1")
.replace(/>\s+/g, "")
.replace(/[-*+]\s+/g, "")
.replace(/\d+\.\s+/g, "")
.replace(/\n{2,}/g, "\n")
.replace(/<[^>]+>/g, "")
.trim();
export const getInitialChatState = (): PersistedChatState => {
if (typeof window === "undefined") {
return { messages: [], conversationId: undefined };
}
try {
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!storedRaw) return { messages: [], conversationId: undefined };
const parsed = JSON.parse(storedRaw) as PersistedChatState;
if (!Array.isArray(parsed.messages)) {
console.error("[GlobalChatbox] Invalid persisted messages format.");
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
return { messages: parsed.messages, conversationId: parsed.conversationId };
} catch (error) {
console.error(
"[GlobalChatbox] Failed to read persisted chat state:",
error,
);
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
};
+647
View File
@@ -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);
});
});
+166
View File
@@ -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 };
};
+182 -27
View File
@@ -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,38 +95,140 @@ 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) && (
<Stack
direction="row"
gap="16px"
alignItems="center"
justifyContent="center"
>
{user?.name && (
<Typography
sx={{
display: {
xs: "none",
sm: "inline-block",
},
}}
variant="subtitle2"
<>
<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="12px"
alignItems="center"
justifyContent="center"
>
{user?.name}
</Typography>
)}
<Avatar src={user?.avatar} alt={user?.name} />
</Stack>
{user?.name && (
<Typography
sx={{
display: {
xs: "none",
sm: "inline-block",
},
fontWeight: 500,
}}
variant="subtitle2"
>
{user?.name}
</Typography>
)}
<Avatar
src={user?.avatar}
alt={user?.name}
sx={{
width: 32,
height: 32,
border: `2px solid ${mode === "dark"
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.1)"
}`,
transition: "transform 0.3s ease",
".MuiButtonBase-root:hover &": {
transform: "rotate(5deg) scale(1.05)",
borderColor: "primary.main",
},
}}
/>
</Stack>
</ButtonBase>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleMenuClose}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
PaperProps={{
sx: {
borderRadius: 2,
minWidth: 180,
marginTop: "8px",
background:
mode === "dark"
? "rgba(30, 30, 30, 0.95)"
: "rgba(255, 255, 255, 0.95)",
backdropFilter: "blur(10px)",
boxShadow:
mode === "dark"
? "0px 4px 20px rgba(0, 0, 0, 0.5)"
: "0px 4px 20px rgba(0, 0, 0, 0.1)",
},
}}
>
<MenuItem onClick={handleSwitchProjectClick}>
<ListItemIcon>
<SwapHoriz fontSize="small" />
</ListItemIcon>
<ListItemText></ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={() => logout()}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
<ListItemText></ListItemText>
</MenuItem>
</Menu>
<ProjectSelector
open={showProjectSelector}
onSelect={handleProjectSelect}
onClose={() => setShowProjectSelector(false)}
/>
</>
)}
<GlobalChatbox
open={showChatbox}
onClose={() => setShowChatbox(false)}
/>
</Stack>
</Stack>
</Toolbar>
+77 -60
View File
@@ -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;
@@ -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">
@@ -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="定位结果"
/>
<Tab
icon={<HandymanIcon fontSize="small" />}
iconPosition="start"
label="关阀分析"
/>
{isBurstMode && (
<Tab
icon={<HandymanIcon fontSize="small" />}
iconPosition="start"
label="关阀分析"
/>
)}
</Tabs>
</Box>
{/* Tab 内容 */}
<TabPanel value={currentTab} index={0}>
{isBurstMode ? (
<AnalysisParameters />
) : (
<ContaminantAnalysisParameters />
)}
<AnalysisParameters />
</TabPanel>
<TabPanel value={currentTab} index={1}>
{isBurstMode ? (
<SchemeQuery
schemes={schemes}
onSchemesChange={setSchemes}
onLocate={handleLocateScheme}
/>
) : (
<ContaminantSchemeQuery onViewResults={() => setCurrentTab(2)} />
)}
<SchemeQuery
schemes={schemes}
onSchemesChange={setSchemes}
onLocate={handleLocateScheme}
/>
</TabPanel>
<TabPanel value={currentTab} index={2}>
{isBurstMode ? (
<LocationResults
results={locationResults}
onAnalyze={handleAnalyzePipe}
/>
) : (
<ContaminantResultsPanel schemeName={data?.schemeName} />
)}
<LocationResults
results={locationResults}
/>
</TabPanel>
{isBurstMode && (
<TabPanel value={currentTab} index={3}>
<ValveIsolation
initialPipeIds={selectedPipeIds}
shouldFetch={valveAnalysisTriggered}
onFetchComplete={() => setValveAnalysisTriggered(false)}
loading={valveAnalysisLoading}
result={valveAnalysisResult}
onLoadingChange={setValveAnalysisLoading}
onResultChange={setValveAnalysisResult}
/>
</TabPanel>
)}
<TabPanel value={currentTab} index={3}>
<ValveIsolation
loading={valveAnalysisLoading}
result={valveAnalysisResult}
onLoadingChange={setValveAnalysisLoading}
onResultChange={setValveAnalysisResult}
/>
</TabPanel>
</Box>
</Drawer>
</>
@@ -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"
@@ -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,17 +122,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
});
}
setSchemes(
filteredResults.map((item: SchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
})),
);
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
if (filteredResults.length === 0) {
open?.({
@@ -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
@@ -17,9 +17,9 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs";
import { useNotification } from "@refinedev/core";
import axios from "axios";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style";
@@ -59,6 +59,32 @@ const AnalysisParameters: React.FC = () => {
);
}, [network, startTime, sourceNode, concentration, duration, schemeName]);
const handleMapClickSelectFeatures = useCallback(
async (event: { coordinate: number[] }) => {
if (!map) return;
const feature = await mapClickSelectFeatures(event, map);
if (!feature) return;
const layerId = feature.getId()?.toString().split(".")[0] || "";
const isJunction = layerId.includes("junction");
if (!isJunction) {
open?.({
type: "error",
message: "请选择节点类型要素作为污染源。",
});
return;
}
const id = feature.getProperties().id;
if (!id) return;
setSourceNode(id);
setHighlightFeature(feature);
setIsSelecting(false);
map.un("click", handleMapClickSelectFeatures);
},
[map, open],
);
useEffect(() => {
if (!map) return;
@@ -106,7 +132,7 @@ const AnalysisParameters: React.FC = () => {
map.removeLayer(layer);
map.un("click", handleMapClickSelectFeatures);
};
}, [map]);
}, [map, handleMapClickSelectFeatures]);
useEffect(() => {
if (!highlightLayer) return;
@@ -118,32 +144,6 @@ const AnalysisParameters: React.FC = () => {
}
}, [highlightFeature, highlightLayer]);
const handleMapClickSelectFeatures = useCallback(
async (event: { coordinate: number[] }) => {
if (!map) return;
const feature = await mapClickSelectFeatures(event, map);
if (!feature) return;
const layerId = feature.getId()?.toString().split(".")[0] || "";
const isJunction = layerId.includes("junction");
if (!isJunction) {
open?.({
type: "error",
message: "请选择节点类型要素作为污染源。",
});
return;
}
const id = feature.getProperties().id;
if (!id) return;
setSourceNode(id);
setHighlightFeature(feature);
setIsSelecting(false);
map.un("click", handleMapClickSelectFeatures);
},
[map, open],
);
const handleStartSelection = () => {
if (!map) return;
setIsSelecting(true);
@@ -175,6 +175,10 @@ const AnalysisParameters: React.FC = () => {
? startTime.format("YYYY-MM-DDTHH:mm:00Z")
: "";
try {
if (!pattern) {
setPattern("CONSTANT");
console.log("默认设置 pattern 为 CONSTANT");
}
const params = {
network,
start_time: start_time,
@@ -185,7 +189,7 @@ const AnalysisParameters: React.FC = () => {
scheme_name: schemeName,
};
await axios.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
await api.get(`${config.BACKEND_URL}/api/v1/contaminant_simulation/`, {
params,
});
@@ -276,7 +280,7 @@ const AnalysisParameters: React.FC = () => {
<Stack spacing={2}>
{sourceNode ? (
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Typography className="flex-shrink-0 text-sm">
<Typography className="flex-shrink-0 pl-1 text-sm">
{sourceNode}
</Typography>
<Typography className="flex-shrink-0 text-sm text-gray-600">
@@ -373,7 +377,7 @@ const AnalysisParameters: React.FC = () => {
size="small"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
placeholder="可选,输入 pattern 名称"
placeholder="可选,输入 pattern 名称,默认为 CONSTANT"
/>
</Box>
@@ -1,30 +0,0 @@
"use client";
import React from "react";
import { Box, Typography } from "@mui/material";
interface ResultsPanelProps {
schemeName?: string;
}
const ResultsPanel: React.FC<ResultsPanelProps> = ({ schemeName }) => {
return (
<Box className="flex flex-col h-full">
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200 p-5">
<Typography variant="h6" className="font-semibold text-gray-900">
</Typography>
<Typography variant="body2" className="text-gray-600 mt-2">
</Typography>
{schemeName && (
<Typography variant="caption" className="text-gray-500 mt-4 block">
{schemeName}
</Typography>
)}
</Box>
</Box>
);
};
export default ResultsPanel;
@@ -25,19 +25,19 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs";
import axios from "axios";
import { api } from "@/lib/api";
import moment from "moment";
import { useNotification } from "@refinedev/core";
import { config, NETWORK_NAME } from "@config/config";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useData, useMap } from "@app/OlMap/MapComponent";
import { useData, useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@app/OlMap/Controls/Timeline";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
interface SchemeQueryProps {
@@ -180,7 +180,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
if (!queryAll && !queryDate) return;
setLoading(true);
try {
const response = await axios.get(
const response = await api.get(
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
);
let filteredResults = response.data;
@@ -195,17 +195,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
);
}
setSchemes(
filteredResults.map((item: ContaminantSchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
})),
);
const nextSchemes = filteredResults.map((item: ContaminantSchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
if (filteredResults.length === 0) {
open?.({
@@ -0,0 +1,209 @@
"use client";
import React, { useState } from "react";
import {
Box,
Drawer,
Tabs,
Tab,
Typography,
IconButton,
Tooltip,
} from "@mui/material";
import {
ChevronRight,
ChevronLeft,
Analytics as AnalyticsIcon,
Search as SearchIcon,
MyLocation as MyLocationIcon,
} from "@mui/icons-material";
import ContaminantAnalysisParameters from "./AnalysisParameters";
import ContaminantSchemeQuery from "./SchemeQuery";
import { useData } from "@components/olmap/core/MapComponent";
import { ContaminantSchemeRecord } from "./types";
interface WaterQualityPanelProps {
open?: boolean;
onToggle?: () => void;
}
const WaterQualityPanel: React.FC<WaterQualityPanelProps> = ({
open: controlledOpen,
onToggle,
}) => {
const [internalOpen, setInternalOpen] = useState(true);
const [currentTab, setCurrentTab] = useState(0);
const [schemes, setSchemes] = useState<ContaminantSchemeRecord[]>([]);
const data = useData();
// 使用受控或非受控状态
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const handleToggle = () => {
if (onToggle) {
onToggle();
} else {
setInternalOpen(!internalOpen);
}
};
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
};
const drawerWidth = 520;
const panelTitle = "水质模拟";
return (
<>
{/* 收起时的触发按钮 */}
{!isOpen && (
<Box
className="absolute top-4 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={handleToggle}
sx={{ zIndex: 1300 }}
>
<Box className="flex flex-col items-center py-3 px-3 gap-1">
<AnalyticsIcon className="text-[#257DD4] w-5 h-5" />
<Typography
variant="caption"
className="text-gray-700 font-semibold my-1 text-xs"
style={{ writingMode: "vertical-rl" }}
>
{panelTitle}
</Typography>
<ChevronLeft className="text-gray-600 w-4 h-4" />
</Box>
</Box>
)}
{/* 主面板 */}
<Drawer
anchor="right"
open={isOpen}
variant="persistent"
hideBackdrop
sx={{
// 关键:容器自身不占用布局宽度
width: 0,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
position: "absolute",
top: 16,
right: 16,
height: "calc(100vh - 32px)",
maxHeight: "850px",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)",
opacity: 0.95,
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
border: "none",
"&:hover": {
opacity: 1,
},
},
}}
>
<Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden">
{/* 头部 */}
<Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white">
<Box className="flex items-center gap-2">
<AnalyticsIcon className="w-5 h-5" />
<Typography variant="h6" className="text-lg font-semibold">
{panelTitle}
</Typography>
</Box>
<Tooltip title="收起">
<IconButton
size="small"
onClick={handleToggle}
sx={{ color: "primary.contrastText" }}
>
<ChevronRight fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box className="border-b border-gray-200 bg-white">
<Tabs
value={currentTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{
minHeight: 48,
"& .MuiTab-root": {
minHeight: 48,
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 500,
transition: "all 0.2s",
},
"& .Mui-selected": {
color: "#257DD4",
},
"& .MuiTabs-indicator": {
backgroundColor: "#257DD4",
},
}}
>
<Tab
icon={<AnalyticsIcon fontSize="small" />}
iconPosition="start"
label="分析要件"
/>
<Tab
icon={<SearchIcon fontSize="small" />}
iconPosition="start"
label="方案查询"
/>
{/* <Tab
icon={<MyLocationIcon fontSize="small" />}
iconPosition="start"
label="模拟结果"
/> */}
</Tabs>
</Box>
{/* Tab 内容 */}
<TabPanel value={currentTab} index={0}>
<ContaminantAnalysisParameters />
</TabPanel>
<TabPanel value={currentTab} index={1}>
<ContaminantSchemeQuery
schemes={schemes}
onSchemesChange={setSchemes}
onViewResults={() => setCurrentTab(2)}
/>
</TabPanel>
</Box>
</Drawer>
</>
);
};
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
return (
<div
role="tabpanel"
hidden={value !== index}
className="flex-1 overflow-hidden flex flex-col"
>
{value === index && (
<Box className="flex-1 overflow-auto p-4">{children}</Box>
)}
</div>
);
};
export default WaterQualityPanel;
@@ -0,0 +1,269 @@
"use client";
import React, { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Collapse,
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config";
import { LeakageResultDetail } from "./types";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
interface Props {
onResult: (result: LeakageResultDetail) => void;
}
const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
const { open } = useNotification();
const [schemeName, setSchemeName] = useState(`DMA_Leak_${Date.now()}`);
const [dmaCount, setDmaCount] = useState<number>(5);
const [startTime, setStartTime] = useState<Dayjs | null>(
dayjs().subtract(2, "hour"),
);
const [endTime, setEndTime] = useState<Dayjs | null>(dayjs());
const [popSize, setPopSize] = useState<number>(10);
const [maxGen, setMaxGen] = useState<number>(50);
const [qSum, setQSum] = useState<number>(1440);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [running, setRunning] = useState(false);
const isValid = useMemo(() => {
if (!schemeName.trim() || !startTime || !endTime) return false;
return startTime.isBefore(endTime) && qSum >= 360;
}, [schemeName, startTime, endTime, qSum]);
const handleRun = async () => {
if (!isValid || !startTime || !endTime) {
open?.({ type: "error", message: "请完善参数并确认时间范围合法" });
return;
}
setRunning(true);
open?.({
key: "dma-leak-analysis-progress",
type: "progress",
message: "方案提交分析中",
undoableTimeout: 3,
});
try {
const response = await api.post(
`${config.BACKEND_URL}/api/v1/leakage/identify/`,
{
network: NETWORK_NAME,
scheme_name: schemeName.trim(),
dma_count: dmaCount,
scada_start: startTime.toISOString(),
scada_end: endTime.toISOString(),
pop_size: popSize,
max_gen: maxGen,
q_sum: toM3s(qSum, FLOW_DISPLAY_UNIT),
q_sum_unit: "m3/s",
output_flow_unit: FLOW_DISPLAY_UNIT,
},
);
onResult(response.data as LeakageResultDetail);
open?.({
key: "dma-leak-analysis-success",
type: "success",
message: "方案分析成功",
description: "DMA 漏损识别完成,请在方案查询中查看结果。",
});
} catch (error: any) {
open?.({
key: "dma-leak-analysis-error",
type: "error",
message: "提交分析失败",
description: error?.response?.data?.detail ?? "请求失败",
});
} finally {
setRunning(false);
}
};
return (
<Box className="flex flex-col flex-1 min-h-0">
<Box className="flex flex-col gap-3">
<Alert severity="info">
DMA DMA
</Alert>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<TextField
value={schemeName}
onChange={(e) => setSchemeName(e.target.value)}
placeholder="请输入方案名称"
fullWidth
size="small"
/>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
DMA
</Typography>
<TextField
type="number"
value={dmaCount}
onChange={(e) => {
const value = Number.parseInt(e.target.value, 10);
// Limit between 3 and 10
if (Number.isNaN(value)) {
setDmaCount(5);
} else if (value > 10) {
setDmaCount(10);
} else {
setDmaCount(Math.max(3, value));
}
}}
fullWidth
size="small"
inputProps={{ min: 3, max: 10, step: 1 }}
helperText="DMA 数量限制为 3-10 个"
/>
</Box>
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
localeText={
pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText
}
>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
SCADA
</Typography>
<DateTimePicker
value={startTime}
onChange={setStartTime}
maxDateTime={endTime ?? undefined}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
SCADA
</Typography>
<DateTimePicker
value={endTime}
onChange={setEndTime}
minDateTime={startTime ?? undefined}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
</LocalizationProvider>
<Box className="flex flex-col gap-2">
<Typography variant="subtitle2" className="mb-1 font-medium">
({FLOW_DISPLAY_UNIT})
</Typography>
<TextField
type="number"
size="small"
value={qSum}
onChange={(e) => {
const value = Number(e.target.value);
setQSum(Number.isNaN(value) ? 1440 : Math.max(360, value));
}}
inputProps={{ min: 360, step: 10 }}
/>
<Box
sx={{
border: "1px solid",
borderColor: "grey.200",
borderRadius: 1,
overflow: "hidden",
}}
>
<Box
role="button"
tabIndex={0}
onClick={() => setAdvancedOpen((prev) => !prev)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setAdvancedOpen((prev) => !prev);
}}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 1.25,
py: 0.75,
cursor: "pointer",
backgroundColor: "transparent",
"&:hover": { backgroundColor: "action.hover" },
}}
>
<Typography variant="body2" color="text.secondary">
</Typography>
<ExpandMoreIcon
sx={{
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
/>
</Box>
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
<Box
sx={{
px: 1.25,
pt: 1.25,
pb: 1.25,
backgroundColor: "transparent",
}}
>
<Box className="grid grid-cols-2 gap-2">
<TextField
type="number"
label="种群规模"
size="small"
value={popSize}
onChange={(e) => setPopSize(Number(e.target.value))}
/>
<TextField
type="number"
label="最大代数"
size="small"
value={maxGen}
onChange={(e) => setMaxGen(Number(e.target.value))}
/>
</Box>
</Box>
</Collapse>
</Box>
</Box>
</Box>
<Box className="mt-auto pt-3">
<Button
fullWidth
variant="contained"
onClick={handleRun}
disabled={!isValid || running}
className="bg-blue-600 hover:bg-blue-700"
>
{running ? "识别中..." : "开始识别"}
</Button>
</Box>
</Box>
);
};
export default AnalysisParameters;
@@ -0,0 +1,308 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Drawer,
Tabs,
Tab,
Typography,
IconButton,
Tooltip,
} from "@mui/material";
import {
Analytics as AnalyticsIcon,
Search as SearchIcon,
ChevronLeft,
ChevronRight,
FormatListBulleted,
} from "@mui/icons-material";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile";
import { VectorTile } from "ol";
import { FlatStyleLike } from "ol/style/flat";
import { useMap } from "@components/olmap/core/MapComponent";
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import AnalysisParameters from "./AnalysisParameters";
import SchemeQuery from "./SchemeQuery";
import RecognitionResults from "./RecognitionResults";
import { getAreaColor } from "./utils";
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { config } from "@/config/config";
const TabPanel = ({
value,
index,
children,
}: {
value: number;
index: number;
children: React.ReactNode;
}) => (
<div role="tabpanel" hidden={value !== index} className="flex-1 overflow-hidden flex flex-col">
{value === index ? <Box className="flex-1 overflow-auto p-4 flex flex-col">{children}</Box> : null}
</div>
);
const DMA_AREA_INDEX_PROPERTY = "dma_area_index";
const DMALeakDetectionPanel: React.FC = () => {
const map = useMap();
const [open, setOpen] = useState(true);
const [tab, setTab] = useState(0);
const [result, setResult] = useState<LeakageResultDetail | null>(null);
const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null);
const [schemes, setSchemes] = useState<LeakageSchemeRecord[]>([]);
const drawerWidth = 450;
const panelTitle = "DMA 漏损识别";
const activeAreas = useMemo(() => loadedResult?.areas ?? [], [loadedResult]);
const legendColors = useMemo(
() => activeAreas.map((area) => getAreaColor(area.area_id)),
[activeAreas],
);
const legendLabels = useMemo(
() => activeAreas.map((area) => `区域 ${area.area_id}`),
[activeAreas],
);
const legendBreaks = useMemo(
() => Array.from({ length: activeAreas.length + 1 }, (_, i) => i + 1),
[activeAreas.length],
);
const handleAnalysisResult = useCallback((res: LeakageResultDetail) => {
setResult(res);
}, []);
const handleViewResult = useCallback((res: LeakageResultDetail) => {
setResult(res);
setLoadedResult(res);
setTab(2);
}, []);
useEffect(() => {
if (!map) return;
const junctionLayer = map
.getAllLayers()
.find(
(layer) =>
layer instanceof WebGLVectorTileLayer && layer.get("value") === "junctions",
) as WebGLVectorTileLayer | undefined;
if (!junctionLayer) return;
const source = junctionLayer.getSource() as VectorTileSource;
if (!source) return;
if (!loadedResult || !loadedResult.node_area_map) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return;
}
const fallbackAreaIds = Array.from(
new Set(Object.values(loadedResult.node_area_map || {}).map(String)),
);
const areaIds = (loadedResult.areas || []).length
? loadedResult.areas.map((area) => String(area.area_id))
: fallbackAreaIds;
if (areaIds.length === 0) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return;
}
const areaIdToIndex = new Map<string, number>();
areaIds.forEach((areaId, index) => {
areaIdToIndex.set(areaId, index + 1);
});
const nodeAreaIndexMap = new Map<string, number>();
Object.entries(loadedResult.node_area_map || {}).forEach(([nodeId, areaId]) => {
const idx = areaIdToIndex.get(String(areaId));
if (idx !== undefined) {
nodeAreaIndexMap.set(String(nodeId), idx);
}
});
const applyFeatureAreaIndex = (renderFeature: any) => {
const featureId = String(renderFeature.get("id") ?? "");
const areaIndex = nodeAreaIndexMap.get(featureId);
if (areaIndex !== undefined) {
renderFeature.properties_[DMA_AREA_INDEX_PROPERTY] = areaIndex;
}
};
const sourceTiles = (source as any).sourceTiles_;
if (sourceTiles) {
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
});
}
const listener = (event: any) => {
try {
if (event.tile instanceof VectorTile) {
const renderFeatures = event.tile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
}
} catch (error) {
console.error("Error applying DMA area mapping:", error);
}
};
source.on("tileloadend", listener);
const fillCases: any[] = [];
areaIds.forEach((areaId, index) => {
fillCases.push(
["==", ["get", DMA_AREA_INDEX_PROPERTY], index + 1],
getAreaColor(areaId),
);
});
const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]);
const defaultStrokeColor = String(
config.MAP_DEFAULT_STYLE["circle-stroke-color"],
);
const dmaStyle: FlatStyleLike = {
...config.MAP_DEFAULT_STYLE,
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
};
junctionLayer.setStyle(dmaStyle);
return () => {
source.un("tileloadend", listener);
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
};
}, [map, loadedResult]);
return (
<>
{!open && (
<Box
className="absolute top-4 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={() => setOpen(true)}
sx={{ zIndex: 1300 }}
>
<Box className="flex flex-col items-center py-3 px-3 gap-1">
<AnalyticsIcon className="text-[#257DD4] w-5 h-5" />
<Typography
variant="caption"
className="text-gray-700 font-semibold my-1 text-xs"
style={{ writingMode: "vertical-rl" }}
>
{panelTitle}
</Typography>
<ChevronLeft className="text-gray-600 w-4 h-4" />
</Box>
</Box>
)}
<Drawer
anchor="right"
open={open}
variant="persistent"
hideBackdrop
sx={{
width: 0,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
position: "absolute",
top: 16,
right: 16,
height: "calc(100vh - 32px)",
maxHeight: "850px",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)",
opacity: 0.95,
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
border: "none",
"&:hover": {
opacity: 1,
},
},
}}
>
<Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden">
<Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white">
<Box className="flex items-center gap-2">
<AnalyticsIcon className="w-5 h-5" />
<Typography variant="h6" className="text-lg font-semibold">
{panelTitle}
</Typography>
</Box>
<Tooltip title="收起">
<IconButton
size="small"
onClick={() => setOpen(false)}
sx={{ color: "primary.contrastText" }}
>
<ChevronRight fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box className="border-b border-gray-200 bg-white">
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
variant="fullWidth"
sx={{
minHeight: 48,
"& .MuiTab-root": {
minHeight: 48,
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 500,
transition: "all 0.2s",
},
"& .Mui-selected": {
color: "#257DD4",
},
"& .MuiTabs-indicator": {
backgroundColor: "#257DD4",
},
}}
>
<Tab icon={<AnalyticsIcon fontSize="small" />} iconPosition="start" label="识别参数" />
<Tab icon={<SearchIcon fontSize="small" />} iconPosition="start" label="方案查询" />
<Tab icon={<FormatListBulleted fontSize="small" />} iconPosition="start" label="识别结果" />
</Tabs>
</Box>
<TabPanel value={tab} index={0}>
<AnalysisParameters onResult={handleAnalysisResult} />
</TabPanel>
<TabPanel value={tab} index={1}>
<SchemeQuery onViewResult={handleViewResult} schemes={schemes} onSchemesChange={setSchemes} />
</TabPanel>
<TabPanel value={tab} index={2}>
<RecognitionResults result={result} />
</TabPanel>
</Box>
</Drawer>
{loadedResult && activeAreas.length > 0 && (
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
<StyleLegend
layerName="节点"
layerId="dma-leakage"
property="区域"
colors={legendColors}
type="point"
dimensions={Array(legendColors.length).fill(10)}
breaks={legendBreaks}
labels={legendLabels}
itemsPerColumn={5}
/>
</Box>
)}
</>
);
};
export default DMALeakDetectionPanel;
@@ -0,0 +1,262 @@
"use client";
import React, { useMemo } from "react";
import {
Box,
Typography,
Chip,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@mui/material";
import { FormatListBulleted } from "@mui/icons-material";
import dayjs from "dayjs";
import { getAreaColor } from "./utils";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { LeakageResultDetail } from "./types";
interface Props {
result: LeakageResultDetail | null;
}
const RecognitionResults: React.FC<Props> = ({ result }) => {
const sortedRows = useMemo(() => {
if (!result?.rows) return [];
return [...result.rows].sort(
(a, b) => b.LeakageFlow_m3_per_s - a.LeakageFlow_m3_per_s,
);
}, [result]);
if (!result || !sortedRows.length) {
return (
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"></Typography>
<Typography variant="body2" className="mt-1">
</Typography>
</Box>
);
}
return (
<Box className="h-full overflow-auto p-1">
{/* 方案详情卡片 */}
<Box className="mb-4 space-y-3">
<Box className="flex items-center justify-between px-1">
<Box className="flex items-center gap-2">
<Box className="w-1 h-4 bg-blue-600 rounded-full" />
<Typography
variant="h6"
className="font-bold text-gray-900 truncate"
sx={{ fontSize: "1.1rem" }}
title={result.scheme_name || ""}
>
{result.scheme_name || "漏损识别结果"}
</Typography>
</Box>
{result.username && (
<Chip
label={result.username}
size="small"
sx={{
height: 24,
backgroundColor: "#f3f4f6",
color: "#4b5563",
border: "none",
fontWeight: 500,
}}
/>
)}
</Box>
<Box className="grid grid-cols-2 gap-3">
{/* 方案时间 */}
<Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm">
<Typography
variant="caption"
className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide"
>
</Typography>
<Typography variant="body2" className="font-bold text-blue-900">
{dayjs(result.scheme_start_time || result.create_time).format(
"MM-DD HH:mm",
)}
</Typography>
</Box>
{/* 总漏损流量 */}
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm">
<Typography
variant="caption"
className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide"
>
</Typography>
<Typography variant="body2" className="font-bold text-orange-900">
{(() => {
const val = (result.scheme_detail as any)?.algorithm_params
?.q_sum;
const unit = String(
(result.scheme_detail as any)?.algorithm_params?.q_sum_unit ||
"m3/s",
);
const qSumM3h = toM3h(Number(val), unit);
return Number.isFinite(qSumM3h)
? `${qSumM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}`
: "-";
})()}
</Typography>
</Box>
{/* 分区数量 */}
<Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm">
<Typography
variant="caption"
className="text-green-700 font-semibold block mb-1 text-xs uppercase tracking-wide"
>
</Typography>
<Typography variant="body2" className="font-bold text-green-900">
{(result.scheme_detail as any)?.result_summary?.area_count ??
result.areas?.length ??
0}{" "}
</Typography>
</Box>
{/* 最大漏损 */}
<Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm">
<Typography
variant="caption"
className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide"
>
</Typography>
<Typography variant="body2" className="font-bold text-purple-900">
{(() => {
const maxL = (result.scheme_detail as any)?.result_summary
?.max_leakage;
const maxLeakageM3h = toM3h(Number(maxL), "m3/s");
return Number.isFinite(maxLeakageM3h)
? `${maxLeakageM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}`
: "-";
})()}
</Typography>
</Box>
</Box>
</Box>
{/* 漏损列表 */}
<Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden">
<Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white">
<Box className="flex items-center gap-2">
<FormatListBulleted className="text-blue-600 w-5 h-5" />
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography>
</Box>
<Chip
size="small"
label={`${sortedRows.length}`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
</Box>
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: "#f8fafc" }}>
<TableCell
sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pl: 3 }}
>
</TableCell>
<TableCell
align="right"
sx={{ fontWeight: 600, color: "#64748b", py: 1.5 }}
>
(%)
</TableCell>
<TableCell
align="right"
sx={{ fontWeight: 600, color: "#64748b", py: 1.5, pr: 3 }}
>
({FLOW_DISPLAY_UNIT})
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedRows.map((row) => (
<TableRow
key={row.Area}
hover
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell sx={{ pl: 3, py: 1.2 }}>
<Box className="flex items-center gap-2">
<Box
className="w-2 h-2 rounded-full"
sx={{ backgroundColor: getAreaColor(row.Area) }}
/>
<Typography
variant="body2"
className="font-medium text-gray-700"
>
{row.Area}
</Typography>
</Box>
</TableCell>
<TableCell align="right" sx={{ py: 1.2, color: "#475569" }}>
{(row.LeakageRatio * 100).toFixed(3)}
</TableCell>
<TableCell
align="right"
sx={{ pr: 3, py: 1.2, fontWeight: 500, color: "#334155" }}
>
{toM3h(Number(row.LeakageFlow_m3_per_s), "m3/s").toFixed(3)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Box>
);
};
export default RecognitionResults;
@@ -0,0 +1,273 @@
"use client";
import React, { useState } from "react";
import {
Box,
Button,
Card,
CardContent,
Chip,
Collapse,
FormControlLabel,
Checkbox,
IconButton,
Tooltip,
Typography,
} from "@mui/material";
import { Info as InfoIcon } from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config";
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
interface Props {
onViewResult: (result: LeakageResultDetail) => void;
schemes?: LeakageSchemeRecord[];
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
}
const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => {
const { open } = useNotification();
const [queryAll, setQueryAll] = useState(true);
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes;
const handleQuery = async () => {
setLoading(true);
try {
const params: Record<string, string> = { network: NETWORK_NAME };
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, {
params,
});
const nextSchemes = response.data as LeakageSchemeRecord[];
setSchemes(nextSchemes);
} catch (error: any) {
open?.({
type: "error",
message: "查询失败",
description: error?.response?.data?.detail ?? "无法获取方案列表",
});
} finally {
setLoading(false);
}
};
const handleViewSchemeResult = async (schemeName: string) => {
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/leakage/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } },
);
onViewResult(response.data as LeakageResultDetail);
} catch (error: any) {
open?.({
type: "error",
message: "查看详情失败",
description: error?.response?.data?.detail ?? "无法获取方案详情",
});
}
};
return (
<Box className="flex flex-col h-full">
<Box className="mb-2 p-2 bg-gray-50 rounded">
<Box className="flex items-center gap-2 justify-between">
<Box className="flex items-center gap-2">
<FormControlLabel
control={
<Checkbox
size="small"
checked={queryAll}
onChange={(e) => setQueryAll(e.target.checked)}
/>
}
label={<Typography variant="body2"></Typography>}
className="m-0"
/>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={setQueryDate}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
/>
</LocalizationProvider>
</Box>
<Button
variant="contained"
onClick={handleQuery}
disabled={loading}
size="small"
className="bg-blue-600 hover:bg-blue-700"
sx={{ minWidth: 80 }}
>
{loading ? "查询中..." : "查询"}
</Button>
</Box>
</Box>
<Box className="flex-1 overflow-auto">
{schemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
{schemes.length}
</Typography>
{schemes.map((scheme) => (
<Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow">
<CardContent className="p-3 pb-2 last:pb-3">
<Box className="flex items-start justify-between gap-2 mb-2">
<Box className="flex-1 min-w-0">
<Box className="flex items-center gap-2 mb-1">
<Typography variant="body2" className="font-medium truncate" title={scheme.scheme_name}>
{scheme.scheme_name}
</Typography>
<Chip size="small" variant="outlined" color="primary" label="DMA漏损" className="h-5" />
</Box>
<Typography variant="caption" className="text-gray-500 block">
ID: {scheme.scheme_id} · : {dayjs(scheme.create_time).format("MM-DD")}
</Typography>
</Box>
<Box className="flex gap-1 ml-2">
<Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}>
<IconButton
size="small"
onClick={() => setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)}
color="primary"
className="p-1"
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Collapse in={expandedId === scheme.scheme_id}>
<Box className="mt-2 pt-3 border-t border-gray-200">
<Box className="mb-3 rounded-md bg-gray-50 px-3 py-2 space-y-2">
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{String((scheme.scheme_detail as any)?.result_summary?.area_count ?? "-")}
</Typography>
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.username || "-"}
</Typography>
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{(() => {
const value = Number((scheme.scheme_detail as any)?.result_summary?.max_leakage);
const maxLeakageM3h = toM3h(value, "m3/s");
return Number.isFinite(maxLeakageM3h)
? `${maxLeakageM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}`
: "-";
})()}
</Typography>
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{(() => {
const value = Number((scheme.scheme_detail as any)?.algorithm_params?.q_sum);
const unit = String(
(scheme.scheme_detail as any)?.algorithm_params?.q_sum_unit || "m3/s",
);
const qSumM3h = toM3h(value, unit);
return Number.isFinite(qSumM3h)
? `${qSumM3h.toFixed(3)} ${FLOW_DISPLAY_UNIT}`
: "-";
})()}
</Typography>
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{dayjs(scheme.scheme_start_time || scheme.create_time).format("YYYY-MM-DD HH:mm")}
</Typography>
</Box>
</Box>
<Box className="pt-2 border-t border-gray-100">
<Button
variant="contained"
fullWidth
size="small"
className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => handleViewSchemeResult(scheme.scheme_name)}
>
</Button>
</Box>
</Box>
</Collapse>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
</Box>
);
};
export default SchemeQuery;
@@ -0,0 +1,53 @@
export interface LeakageRow {
Area: string;
LeakageRatioRaw: number;
LeakageRatio: number;
LeakageFlow_m3_per_s: number;
}
export interface LeakageSchemeRecord {
scheme_id: number;
scheme_name: string;
scheme_type: string;
username: string;
create_time: string;
scheme_start_time: string;
scheme_detail: Record<string, unknown>;
}
export interface LeakageResultDetail {
scheme_name?: string;
network?: string;
sensor_nodes: string[];
rows: LeakageRow[];
node_area_map: Record<string, string>;
areas: Array<{
area_id: string;
node_count: number;
node_ids: string[];
sensor_nodes: string[];
}>;
drawing_payload: {
type: "FeatureCollection";
features: Array<Record<string, unknown>>;
};
node_visual_payload?: {
type: "FeatureCollection";
features: Array<Record<string, unknown>>;
};
scheme_detail?: {
algorithm_params?: {
q_sum?: number;
q_sum_unit?: string;
[key: string]: unknown;
};
result_summary?: {
area_count?: number;
max_leakage?: number;
};
[key: string]: unknown;
};
scheme_start_time?: string;
create_time?: string;
username?: string;
}
@@ -0,0 +1,21 @@
export const AREA_COLORS = [
"#2563eb",
"#7c3aed",
"#0891b2",
"#16a34a",
"#ca8a04",
"#dc2626",
"#ea580c",
"#0f766e",
"#4338ca",
"#be123c",
];
export const getAreaColor = (areaId: string | number | undefined) => {
const text = String(areaId ?? "");
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
}
return AREA_COLORS[hash % AREA_COLORS.length];
};
@@ -0,0 +1,451 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import {
Box,
TextField,
Button,
Typography,
IconButton,
Stack,
Alert,
Divider,
} from "@mui/material";
import { Close as CloseIcon } from "@mui/icons-material";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs";
import { useMap } from "@components/olmap/core/MapComponent";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Stroke, Fill, Circle as CircleStyle } from "ol/style";
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import Feature, { FeatureLike } from "ol/Feature";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
interface ValveItem {
id: string;
k: number;
feature?: any;
}
const AnalysisParameters: React.FC = () => {
const map = useMap();
const { open } = useNotification();
// State
const [schemeName, setSchemeName] = useState<string>(
"Flushing_" + new Date().getTime(),
);
const [valves, setValves] = useState<ValveItem[]>([]);
const [drainageNode, setDrainageNode] = useState<string | null>(null);
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
const [startTime, setStartTime] = useState<Dayjs | null>(dayjs(new Date()));
const [flushFlow, setFlushFlow] = useState<number>(200);
const [duration, setDuration] = useState<number>(3600);
const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none');
const [analyzing, setAnalyzing] = useState<boolean>(false);
const [highlightLayer, setHighlightLayer] = useState<VectorLayer<VectorSource> | null>(null);
// Map click handler
const handleMapClickSelectFeatures = useCallback(
async (event: { coordinate: number[] }) => {
if (!map || selectionMode === 'none') return;
const feature = await mapClickSelectFeatures(event, map);
if (!feature) return;
const layer = feature.getId()?.toString().split(".")[0];
const featureId = feature.getProperties().id;
if (selectionMode === 'valve') {
if (layer !== 'geo_valves') {
open?.({
type: "error",
message: "请选择阀门要素",
});
return;
}
setValves((prev) => {
if (prev.some((v) => v.id === featureId)) {
open?.({
type: "error",
message: "该阀门已添加",
});
return prev;
}
return [...prev, { id: featureId, k: 1.0, feature }];
});
} else if (selectionMode === 'drainage') {
if (layer !== 'geo_junctions') {
open?.({
type: "error",
message: "请选择节点要素作为排水点",
});
return;
}
setDrainageNode(featureId);
setDrainageFeature(feature);
setSelectionMode('none');
map.un("click", handleMapClickSelectFeatures);
}
},
[map, selectionMode, open]
);
// Initialize highlight layer
useEffect(() => {
if (!map) return;
const highlightStyle = function (feature: FeatureLike) {
const styles = [];
const type = feature.get("type"); // We will set this property when adding to source
if (type === "valve") {
styles.push(
new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: "rgba(255, 165, 0, 0.8)" }), // Orange for valves
stroke: new Stroke({ color: "white", width: 2 }),
}),
})
);
} else if (type === "drainage") {
styles.push(
new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: "rgba(0, 0, 255, 0.8)" }), // Blue for drainage
stroke: new Stroke({ color: "white", width: 2 }),
}),
})
);
}
return styles;
};
const layer = new VectorLayer({
source: new VectorSource(),
style: highlightStyle,
zIndex: 1000,
properties: {
name: "FlushingHighlight",
},
});
map.addLayer(layer);
setHighlightLayer(layer);
return () => {
map.removeLayer(layer);
map.un("click", handleMapClickSelectFeatures);
};
}, [map, handleMapClickSelectFeatures]);
// Update highlight layer features
useEffect(() => {
if (!highlightLayer) return;
const source = highlightLayer.getSource();
if (!source) return;
source.clear();
// Add valves
valves.forEach((v) => {
if (v.feature) {
const f = v.feature.clone(); // Clone to avoid modifying original
f.set("type", "valve");
// Ensure geometry is present (it should be for features from map)
if (f.getGeometry()) {
source.addFeature(f);
}
}
});
// Add drainage node
if (drainageFeature) {
const f = drainageFeature.clone();
f.set("type", "drainage");
source.addFeature(f);
}
}, [highlightLayer, valves, drainageFeature]);
// Bind click event based on selection mode
useEffect(() => {
if (!map || selectionMode === "none") return;
map.on("click", handleMapClickSelectFeatures);
return () => {
map.un("click", handleMapClickSelectFeatures);
};
}, [map, selectionMode, handleMapClickSelectFeatures]);
// Toggle selection
const toggleSelection = (mode: 'valve' | 'drainage') => {
// If clicking same mode, turn off
if (selectionMode === mode) {
setSelectionMode('none');
} else {
setSelectionMode(mode);
}
};
const handleRemoveValve = (id: string) => {
setValves((prev) => prev.filter((v) => v.id !== id));
};
const handleValveKChange = (id: string, k: string) => {
const numK = parseFloat(k);
setValves(prev => prev.map(v => v.id === id ? { ...v, k: isNaN(numK) ? 0 : numK } : v));
};
const handleAnalyze = async () => {
if (!startTime || !drainageNode || !schemeName.trim()) {
open?.({
type: "error",
message: "请填写完整参数",
description: "方案名称、开始时间和排水点为必填项",
});
return;
}
setAnalyzing(true);
try {
const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00
const params = {
scheme_name: schemeName,
network: NETWORK_NAME,
start_time: formattedTime,
valves: valves.map(v => v.id),
valves_k: valves.map(v => v.k),
drainage_node_ID: drainageNode,
flush_flow: flushFlow,
duration: duration
};
// Use params serializer to handle array params correctly if needed,
// but axios usually handles array as valves[]=1&valves[]=2
// FastAPI default expects repeated query params.
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing_analysis/`, {
params,
// Ensure arrays are sent as repeated keys: valves=1&valves=2
paramsSerializer: {
indexes: null // Result: valves=1&valves=2
}
});
if (response.status !== 200) {
throw new Error(`分析请求失败,状态码: ${response.status}`);
}
open?.({
type: "success",
message: "方案分析成功",
description: "管道冲洗模拟完成,请在方案查询中查看结果。",
});
} catch (error) {
console.error("提交分析失败", error);
open?.({
type: "error",
message: "提交分析失败",
description: error instanceof Error ? error.message : "未知错误",
});
} finally {
setAnalyzing(false);
}
};
return (
<Box className="flex flex-col h-full gap-4 pb-4">
{/* 1. Valve Selection */}
<Box>
<Box className="flex items-center justify-between mb-2">
<Typography variant="subtitle2" className="font-medium">
</Typography>
<Button
variant={selectionMode === 'valve' ? "contained" : "outlined"}
color={selectionMode === 'valve' ? "error" : "primary"}
size="small"
onClick={() => toggleSelection('valve')}
>
{selectionMode === 'valve' ? "停止选择" : "选择阀门"}
</Button>
</Box>
{selectionMode === 'valve' && (
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
💡
</Box>
)}
<Stack spacing={1} className="max-h-50 h-48 overflow-auto">
{valves.map((valve) => (
<Box key={valve.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Typography className="text-sm flex-1 pl-1">{valve.id}</Typography>
<TextField
label="开度"
size="small"
type="number"
value={valve.k}
onChange={(e) => handleValveKChange(valve.id, e.target.value)}
className="w-20"
slotProps={{ htmlInput: { step: 0.1, min: 0, max: 1 } }}
/>
<IconButton size="small" onClick={() => handleRemoveValve(valve.id)}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
))}
{valves.length === 0 && (
<Typography variant="caption" className="text-gray-400 text-center py-20">
</Typography>
)}
</Stack>
</Box>
<Divider />
{/* 2. Drainage Node Selection */}
<Box>
<Box className="flex items-center justify-between mb-2">
<Typography variant="subtitle2" className="font-medium">
</Typography>
<Button
variant={selectionMode === 'drainage' ? "contained" : "outlined"}
color={selectionMode === 'drainage' ? "error" : "primary"}
size="small"
onClick={() => toggleSelection('drainage')}
>
{selectionMode === 'drainage' ? "停止选择" : "选择节点"}
</Button>
</Box>
{selectionMode === 'drainage' && (
<Box className="mb-2 p-2 bg-blue-50 text-xs text-blue-700 rounded">
💡
</Box>
)}
<Stack spacing={1} className="h-12 overflow-auto">
{drainageNode && (
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography>
<IconButton
size="small"
onClick={() => {
setDrainageNode(null);
setDrainageFeature(null);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
)}
{!drainageNode && (
<Typography variant="caption" className="text-gray-400 text-center py-2">
</Typography>
)}
</Stack>
</Box>
<Divider />
{/* 3. Parameters */}
<Box className="flex flex-col gap-3">
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DateTimePicker
value={startTime}
onChange={(newValue) => setStartTime(newValue)}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
localeText={
pickerZhCN.components.MuiLocalizationProvider.defaultProps
.localeText
}
/>
</LocalizationProvider>
</Box>
{/* Scheme Name */}
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
</Typography>
<TextField
fullWidth
size="small"
value={schemeName}
onChange={(e) => setSchemeName(e.target.value)}
placeholder="请输入方案名称"
/>
</Box>
<Box className="flex gap-2">
<Box className="flex-1">
<Typography variant="subtitle2" className="mb-1 font-medium">
(CMH)
</Typography>
<TextField
fullWidth
size="small"
type="number"
value={flushFlow}
onChange={(e) => setFlushFlow(parseFloat(e.target.value) || 0)}
/>
</Box>
<Box className="flex-1">
<Typography variant="subtitle2" className="mb-1 font-medium">
()
</Typography>
<TextField
fullWidth
size="small"
type="number"
value={duration}
onChange={(e) => setDuration(parseInt(e.target.value) || 0)}
/>
</Box>
</Box>
</Box>
<Box className="mt-auto pt-2">
<Button
fullWidth
variant="contained"
onClick={handleAnalyze}
disabled={
analyzing ||
!schemeName.trim() ||
!drainageNode ||
!startTime ||
// !flushFlow ||
!duration
}
className="bg-blue-600 hover:bg-blue-700"
>
{analyzing ? "分析中..." : "开始分析"}
</Button>
</Box>
</Box>
);
};
export default AnalysisParameters;
@@ -0,0 +1,196 @@
"use client";
import React, { useState } from "react";
import {
Box,
Drawer,
Tabs,
Tab,
Typography,
IconButton,
Tooltip,
} from "@mui/material";
import {
ChevronRight,
ChevronLeft,
Analytics as AnalyticsIcon,
Search as SearchIcon,
} from "@mui/icons-material";
import { MdCleaningServices } from "react-icons/md";
import AnalysisParameters from "./AnalysisParameters";
import SchemeQuery from "./SchemeQuery";
import { SchemeRecord } from "./types";
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
return (
<div
role="tabpanel"
hidden={value !== index}
className="flex-1 overflow-hidden flex flex-col"
>
{value === index && (
<Box className="flex-1 overflow-auto p-4">{children}</Box>
)}
</div>
);
};
interface FlushingAnalysisPanelProps {
open?: boolean;
onToggle?: () => void;
}
const FlushingAnalysisPanel: React.FC<FlushingAnalysisPanelProps> = ({
open: controlledOpen,
onToggle,
}) => {
const [internalOpen, setInternalOpen] = useState(true);
const [currentTab, setCurrentTab] = useState(0);
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
// Using controlled or uncontrolled state
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const handleToggle = () => {
if (onToggle) {
onToggle();
} else {
setInternalOpen(!internalOpen);
}
};
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
};
const drawerWidth = 450; // Slightly narrower than burst analysis as we have fewer tabs
const panelTitle = "管道冲洗分析";
return (
<>
{/* Toggle Button when closed */}
{!isOpen && (
<Box
className="absolute top-4 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={handleToggle}
sx={{ zIndex: 1300 }}
>
<Box className="flex flex-col items-center py-3 px-3 gap-1">
<MdCleaningServices className="text-[#257DD4] w-5 h-5" />
<Typography
variant="caption"
className="text-gray-700 font-semibold my-1 text-xs"
style={{ writingMode: "vertical-rl" }}
>
{panelTitle}
</Typography>
<ChevronLeft className="text-gray-600 w-4 h-4" />
</Box>
</Box>
)}
{/* Main Panel */}
<Drawer
anchor="right"
open={isOpen}
variant="persistent"
hideBackdrop
sx={{
width: 0,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
position: "absolute",
top: 16,
right: 16,
height: "calc(100vh - 32px)",
maxHeight: "850px",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)",
opacity: 0.95,
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
border: "none",
"&:hover": {
opacity: 1,
},
},
}}
>
<Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden">
{/* Header */}
<Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white">
<Box className="flex items-center gap-2">
<MdCleaningServices className="w-5 h-5" />
<Typography variant="h6" className="text-lg font-semibold">
{panelTitle}
</Typography>
</Box>
<Tooltip title="收起">
<IconButton
size="small"
onClick={handleToggle}
sx={{ color: "primary.contrastText" }}
>
<ChevronRight fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box className="border-b border-gray-200 bg-white">
<Tabs
value={currentTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{
minHeight: 48,
"& .MuiTab-root": {
minHeight: 48,
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 500,
transition: "all 0.2s",
},
"& .Mui-selected": {
color: "#257DD4",
},
"& .MuiTabs-indicator": {
backgroundColor: "#257DD4",
},
}}
>
<Tab
icon={<AnalyticsIcon fontSize="small" />}
iconPosition="start"
label="分析参数"
/>
<Tab
icon={<SearchIcon fontSize="small" />}
iconPosition="start"
label="方案查询"
/>
</Tabs>
</Box>
{/* Tab Content */}
<TabPanel value={currentTab} index={0}>
<AnalysisParameters />
</TabPanel>
<TabPanel value={currentTab} index={1}>
<SchemeQuery schemes={schemes} onSchemesChange={setSchemes} />
</TabPanel>
</Box>
</Drawer>
</>
);
};
export default FlushingAnalysisPanel;
@@ -0,0 +1,613 @@
"use client";
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
import {
Box,
Button,
Typography,
Checkbox,
FormControlLabel,
IconButton,
Card,
CardContent,
Chip,
Tooltip,
Collapse,
Link,
} from "@mui/material";
import {
Info as InfoIcon,
LocationOn as LocationIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs";
import { api } from "@/lib/api";
import moment from "moment";
import { config, NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core";
import { useData, useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemeRecord, SchemaItem } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units";
interface SchemeQueryProps {
schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void;
network?: string;
}
const SCHEME_TYPE = "flushing_analysis";
const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes,
onSchemesChange,
network = NETWORK_NAME,
}) => {
const [queryAll, setQueryAll] = useState<boolean>(true);
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs(new Date()));
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
// Timeline related state
const [showTimeline, setShowTimeline] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [timeRange, setTimeRange] = useState<{ start: Date; end: Date } | undefined>();
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
const { open } = useNotification();
const map = useMap();
const data = useData();
const { schemeName, setSchemeName } = data || {};
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes;
useEffect(() => {
if (!map) return;
const target = map.getTargetElement();
if (target) {
setMapContainer(target);
}
}, [map]);
// Initialize highlight layer
useEffect(() => {
if (!map) return;
const themeColor = "rgba(0, 0, 255"; // Blue for drainage
const valveColor = "rgba(255, 165, 0"; // Orange for valves
const sourceStyle = function (feature: FeatureLike) {
const type = (feature as any).get("type");
if (type === "valve") {
return [
new Style({
image: new Circle({
radius: 8,
fill: new Fill({ color: `${valveColor}, 0.8)` }),
stroke: new Stroke({ color: "white", width: 2 }),
}),
})
];
} else {
// Default drainage
return [
new Style({
image: new Circle({
radius: 12,
fill: new Fill({ color: `${themeColor}, 0.2)` }),
}),
}),
new Style({
image: new Circle({
radius: 8,
stroke: new Stroke({ color: `${themeColor}, 0.5)`, width: 2 }),
fill: new Fill({ color: `${themeColor}, 0.3)` }),
}),
}),
new Style({
image: new Circle({
radius: 4,
fill: new Fill({ color: `${themeColor}, 1)` }),
stroke: new Stroke({ color: "white", width: 1 }),
})
}),
];
}
};
const layer = new VectorLayer({
source: new VectorSource(),
style: sourceStyle,
zIndex: 1000,
properties: {
name: "FlushingQueryResultHighlight",
},
});
map.addLayer(layer);
setHighlightLayer(layer);
return () => {
map.removeLayer(layer);
};
}, [map]);
// Update highlight features
useEffect(() => {
if (!highlightLayer) return;
const source = highlightLayer.getSource();
if (!source) return;
source.clear();
highlightFeatures.forEach((feature) => {
if (feature instanceof Feature) {
source.addFeature(feature);
}
});
}, [highlightFeatures, highlightLayer]);
const handleLocateDrainageNode = (nodeId: string) => {
if (!nodeId) return;
queryFeaturesByIds([nodeId], "geo_junctions_mat").then((features) => {
if (features.length > 0) {
// Add type property to distinguish styling
features.forEach(f => f.set("type", "drainage"));
setHighlightFeatures(features);
zoomToFeatures(features);
} else {
open?.({
type: "error",
message: "未找到该节点要素",
});
}
});
};
const handleLocateValves = (valveIds: string[]) => {
if (!valveIds || valveIds.length === 0) return;
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
if (features.length > 0) {
features.forEach(f => f.set("type", "valve"));
setHighlightFeatures(features);
zoomToFeatures(features);
} else {
open?.({
type: "error",
message: "未找到阀门要素",
});
}
});
};
const zoomToFeatures = (features: Feature[]) => {
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
padding: [50, 50, 50, 50],
});
}
};
const formatTime = (timeStr: string) => {
return moment(timeStr).format("MM-DD HH:mm");
};
const handleQuery = async () => {
if (!queryAll && !queryDate) return;
setLoading(true);
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/getallschemes/?network=${network}`,
);
let filteredResults = response.data;
// Filter by type
filteredResults = filteredResults.filter((item: SchemaItem) => item.scheme_type === SCHEME_TYPE);
if (!queryAll && queryDate) {
const formattedDate = queryDate.format("YYYY-MM-DD");
filteredResults = filteredResults.filter((item: SchemaItem) => {
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
return itemDate === formattedDate;
});
}
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
if (filteredResults.length === 0) {
open?.({
type: "error",
message: "未找到相关方案",
description: "请尝试更改查询条件",
});
}
} catch (error) {
console.error("查询请求失败:", error);
open?.({
type: "error",
message: "查询失败",
description: "获取方案列表失败,请稍后重试",
});
} finally {
setLoading(false);
}
};
const handleViewResults = (scheme: SchemeRecord) => {
setShowTimeline(true);
const schemeDate = scheme.startTime ? new Date(scheme.startTime) : undefined;
if (scheme.startTime && scheme.schemeDetail?.duration) {
const start = new Date(scheme.startTime);
const end = new Date(start.getTime() + scheme.schemeDetail.duration * 1000);
setSelectedDate(schemeDate);
setTimeRange({ start, end });
}
setSchemeName?.(scheme.schemeName);
// Locate drainage node by default if available
if (scheme.schemeDetail?.drainage_node_ID) {
handleLocateDrainageNode(scheme.schemeDetail.drainage_node_ID);
}
};
return (
<>
{showTimeline &&
mapContainer &&
ReactDOM.createPortal(
<Timeline
schemeDate={selectedDate}
timeRange={timeRange}
disableDateSelection={!!timeRange}
schemeName={schemeName}
schemeType={SCHEME_TYPE}
/>,
mapContainer,
)}
<Box className="flex flex-col h-full">
{/* Query Controls */}
<Box className="mb-2 p-2 bg-gray-50 rounded">
<Box className="flex items-center gap-2 justify-between">
<Box className="flex items-center gap-2">
<FormControlLabel
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryAll(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2"></Typography>}
className="m-0"
/>
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryDate(value)
}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
textField: {
size: "small",
sx: { width: 200 },
},
}}
/>
</LocalizationProvider>
</Box>
<Button
variant="contained"
onClick={handleQuery}
disabled={loading}
size="small"
className="bg-blue-600 hover:bg-blue-700"
sx={{ minWidth: 80 }}
>
{loading ? "查询中..." : "查询"}
</Button>
</Box>
</Box>
{/* Results List */}
<Box className="flex-1 overflow-auto">
{schemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
{schemes.length}
</Typography>
{schemes.map((scheme) => (
<Card
key={scheme.id}
variant="outlined"
className="hover:shadow-md transition-shadow"
>
<CardContent className="p-3 pb-2 last:pb-3">
<Box className="flex items-start justify-between mb-2">
<Box className="flex-1 min-w-0">
<Box className="flex items-center gap-2 mb-1">
<Typography
variant="body2"
className="font-medium truncate"
title={scheme.schemeName}
>
{scheme.schemeName}
</Typography>
<Chip
label="冲洗模拟"
size="small"
className="h-5"
color="primary"
variant="outlined"
/>
</Box>
<Typography
variant="caption"
className="text-gray-500 block"
>
: {scheme.user} · : {formatTime(scheme.create_time)}
</Typography>
</Box>
<Box className="flex gap-1 ml-2">
<Tooltip title="定位排水口">
<IconButton
size="small"
onClick={() =>
scheme.schemeDetail?.drainage_node_ID &&
handleLocateDrainageNode(scheme.schemeDetail.drainage_node_ID)
}
color="primary"
className="p-1"
>
<LocationIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip
title={
expandedId === scheme.id ? "收起详情" : "查看详情"
}
>
<IconButton
size="small"
onClick={() =>
setExpandedId(
expandedId === scheme.id ? null : scheme.id,
)
}
color="primary"
className="p-1"
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Collapse in={expandedId === scheme.id}>
<Box className="mt-2 pt-3 border-t border-gray-200">
<Box className="grid grid-cols-2 gap-x-4 gap-y-3 mb-3">
<Box className="space-y-2">
<Box className="space-y-1.5 pl-2">
{/* 排水节点 */}
<Box className="flex items-start gap-2">
<Typography variant="caption" className="text-gray-600 min-w-[70px] mt-0.5">
:
</Typography>
<Box className="flex-1">
{scheme.schemeDetail?.drainage_node_ID ? (
<Link
component="button"
variant="caption"
className="font-medium text-blue-600 hover:text-blue-800 underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleLocateDrainageNode(scheme.schemeDetail!.drainage_node_ID);
}}
>
{scheme.schemeDetail.drainage_node_ID}
</Link>
) : (
<Typography variant="caption" className="font-medium text-gray-900">
N/A
</Typography>
)}
</Box>
</Box>
{/* 冲洗流量 */}
<Box className="flex items-center gap-2">
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.schemeDetail?.flushing_flow ?? "-"} {FLOW_DISPLAY_UNIT}
</Typography>
</Box>
{/* 持续时长 */}
<Box className="flex items-center gap-2">
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.schemeDetail?.duration ?? "-"}
</Typography>
</Box>
</Box>
</Box>
<Box className="space-y-2">
<Box className="space-y-1.5 pl-2">
{/* 用户 */}
<Box className="flex items-center gap-2">
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.user}
</Typography>
</Box>
{/* 创建时间 */}
<Box className="flex items-center gap-2">
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{formatTime(scheme.create_time)}
</Typography>
</Box>
{/* 开始时间 */}
<Box className="flex items-center gap-2">
<Typography variant="caption" className="text-gray-600 min-w-[70px]">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{formatTime(scheme.startTime)}
</Typography>
</Box>
</Box>
</Box>
{/* 阀门列表 */}
<Box className="col-span-2 pl-2">
<Typography variant="caption" className="text-gray-600 block mb-1">
:
</Typography>
<Box className="flex flex-wrap gap-2">
{scheme.schemeDetail?.valve_opening && Object.entries(scheme.schemeDetail.valve_opening).length > 0 ? (
Object.entries(scheme.schemeDetail.valve_opening).map(([id, k]) => (
<Tooltip key={id} title="点击定位阀门">
<Chip
label={`${id}: ${k}`}
size="small"
variant="outlined"
onClick={() => handleLocateValves([id])}
className="text-xs h-6 bg-gray-50 cursor-pointer hover:bg-orange-50 hover:border-orange-200"
/>
</Tooltip>
))
) : (
<Typography variant="caption" className="text-gray-400"></Typography>
)}
</Box>
</Box>
</Box>
<Box className="pt-2 border-t border-gray-100 flex gap-2">
<Button
variant="outlined"
fullWidth
size="small"
className="border-blue-600 text-blue-600 hover:bg-blue-50"
onClick={() =>
scheme.schemeDetail?.drainage_node_ID &&
handleLocateDrainageNode(scheme.schemeDetail.drainage_node_ID)
}
startIcon={<LocationIcon className="w-4 h-4" />}
sx={{ textTransform: "none", fontWeight: 500 }}
>
</Button>
<Button
variant="contained"
fullWidth
size="small"
className="bg-blue-600 hover:bg-blue-700"
onClick={() => handleViewResults(scheme)}
sx={{ textTransform: "none", fontWeight: 500 }}
>
</Button>
</Box>
</Box>
</Collapse>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
</Box>
</>
);
};
export default SchemeQuery;
@@ -0,0 +1,27 @@
export interface SchemeDetail {
valve_opening: Record<string, number>;
drainage_node_ID: string;
flushing_flow: number;
duration: number;
}
export interface SchemeRecord {
id: number;
schemeName: string;
type: string;
user: string;
create_time: string;
startTime: string;
// 详情信息
schemeDetail?: SchemeDetail;
}
export interface SchemaItem {
scheme_id: number;
scheme_name: string;
scheme_type: string;
username: string;
create_time: string;
scheme_start_time: string;
scheme_detail?: SchemeDetail;
}
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useNotification } from "@refinedev/core";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
@@ -27,9 +27,9 @@ import dayjs from "dayjs";
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb";
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { useData } from "../../../app/OlMap/MapComponent";
import { config, NETWORK_NAME } from "@/config/config";
import { useMap } from "../../../app/OlMap/MapComponent";
import { apiFetch } from "@/lib/apiFetch";
import { useMap } from "@components/olmap/core/MapComponent";
import { useHealthRisk } from "./HealthRiskContext";
import {
PredictionResult,
@@ -62,10 +62,6 @@ interface TimelineProps {
const Timeline: React.FC<TimelineProps> = ({
disableDateSelection = false,
}) => {
const data = useData();
if (!data) {
return <div>Loading...</div>; // 或其他占位符
}
const { open } = useNotification();
const {
predictionResults,
@@ -78,7 +74,6 @@ const Timeline: React.FC<TimelineProps> = ({
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
const [isPredicting, setIsPredicting] = useState<boolean>(false);
const [pipeLayer, setPipeLayer] = useState<WebGLVectorTileLayer | null>(null);
// 使用 ref 存储当前的健康数据,供事件监听器读取,避免重复绑定
const healthDataRef = useRef<Map<string, number>>(new Map());
@@ -122,7 +117,7 @@ const Timeline: React.FC<TimelineProps> = ({
setCurrentYear(value);
}, 500); // 500ms 防抖延迟
},
[minTime, maxTime],
[minTime, maxTime, setCurrentYear],
);
// 播放控制
@@ -138,7 +133,7 @@ const Timeline: React.FC<TimelineProps> = ({
});
}, playInterval);
}
}, [isPlaying, playInterval]);
}, [isPlaying, playInterval, maxTime, minTime, setCurrentYear]);
const handlePause = useCallback(() => {
setIsPlaying(false);
@@ -177,7 +172,7 @@ const Timeline: React.FC<TimelineProps> = ({
if (next < minTime) next += maxTime - minTime + 1;
return next;
});
}, [minTime, maxTime]);
}, [minTime, maxTime, setCurrentYear]);
const handleStepForward = useCallback(() => {
setCurrentYear((prev: number) => {
@@ -185,7 +180,7 @@ const Timeline: React.FC<TimelineProps> = ({
if (next > maxTime) next = minTime;
return next;
});
}, [minTime, maxTime]);
}, [minTime, maxTime, setCurrentYear]);
// 日期时间选择处理
const handleDateTimeChange = useCallback((newDate: Date | null) => {
@@ -212,7 +207,7 @@ const Timeline: React.FC<TimelineProps> = ({
}, newInterval);
}
},
[isPlaying],
[isPlaying, maxTime, minTime, setCurrentYear],
);
// 组件加载时设置初始时间为当前时间的最近15分钟
@@ -227,10 +222,21 @@ const Timeline: React.FC<TimelineProps> = ({
clearTimeout(debounceRef.current);
}
};
}, [pipeLayer]);
}, []);
// 获取地图实例
const map = useMap();
const pipeLayer = useMemo(() => {
if (!map) return null;
const layers = map.getLayers().getArray();
return (
layers.find(
(layer) =>
layer instanceof WebGLVectorTileLayer && layer.get("value") === "pipes",
) as WebGLVectorTileLayer | undefined
) ?? null;
}, [map]);
// 根据索引从 survival_function 中获取生存概率
const getSurvivalProbabilityAtYear = useCallback(
@@ -361,27 +367,12 @@ const Timeline: React.FC<TimelineProps> = ({
updatePipeHealthData,
]);
// 初始化管道图层
useEffect(() => {
if (!map) return;
const layers = map.getLayers().getArray();
const pipesLayer = layers.find(
(layer) =>
layer instanceof WebGLVectorTileLayer && layer.get("value") === "pipes",
) as WebGLVectorTileLayer | undefined;
if (pipesLayer) {
setPipeLayer(pipesLayer);
}
}, [map]);
// 监听依赖变化,更新样式
useEffect(() => {
if (predictionResults.length > 0 && pipeLayer) {
applyPipeHealthStyle();
}
}, [applyPipeHealthStyle]);
}, [applyPipeHealthStyle, pipeLayer, predictionResults.length]);
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
useEffect(() => {
@@ -422,7 +413,7 @@ const Timeline: React.FC<TimelineProps> = ({
undoableTimeout: 3,
});
try {
const response = await fetch(
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
);
@@ -484,14 +475,16 @@ const Timeline: React.FC<TimelineProps> = ({
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
>
<Tooltip title="后退一天">
<IconButton
color="primary"
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
>
<FiSkipBack />
</IconButton>
<span>
<IconButton
color="primary"
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
>
<FiSkipBack />
</IconButton>
</span>
</Tooltip>
{/* 日期时间选择器 */}
<DateTimePicker
@@ -514,18 +507,20 @@ const Timeline: React.FC<TimelineProps> = ({
ampm={false}
/>
<Tooltip title="前进一天">
<IconButton
color="primary"
onClick={handleDayStepForward}
size="small"
disabled={
disableDateSelection ||
selectedDateTime.toDateString() ===
new Date().toDateString()
}
>
<FiSkipForward />
</IconButton>
<span>
<IconButton
color="primary"
onClick={handleDayStepForward}
size="small"
disabled={
disableDateSelection ||
selectedDateTime.toDateString() ===
new Date().toDateString()
}
>
<FiSkipForward />
</IconButton>
</span>
</Tooltip>
{/* 播放控制按钮 */}
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
@@ -12,12 +12,12 @@ import {
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { useNotification } from "@refinedev/core";
import { useGetIdentity } from "@refinedev/core";
import axios from "axios";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
type IUser = {
id: number;
name: string;
id: string;
name?: string;
};
const OptimizationParameters: React.FC = () => {
@@ -83,7 +83,7 @@ const OptimizationParameters: React.FC = () => {
setAnalyzing(true);
if (!user || !user.name) {
if (!user || !user.id) {
open?.({
type: "error",
message: "用户信息无效",
@@ -93,7 +93,7 @@ const OptimizationParameters: React.FC = () => {
try {
// 发送优化请求
const response = await axios.post(
const response = await api.post(
`${config.BACKEND_URL}/api/v1/sensorplacementscheme/create`,
null,
{
@@ -104,6 +104,7 @@ const OptimizationParameters: React.FC = () => {
method: method,
sensor_count: sensorCount,
min_diameter: minDiameter,
user_id: user.id,
user_name: user.name,
},
}
@@ -24,11 +24,11 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "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 { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
@@ -140,7 +140,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
source.addFeature(feature);
}
});
}, [highlightFeatures]);
}, [highlightFeatures, highlightLayer]);
// 查询方案
const handleQuery = async () => {
@@ -148,7 +148,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true);
try {
const response = await axios.get(
const response = await api.get(
`${config.BACKEND_URL}/api/v1/getallsensorplacements/?network=${network}`,
);
@@ -163,17 +163,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
});
}
setSchemes(
filteredResults.map((item: SchemaItem) => ({
id: item.id,
schemeName: item.scheme_name,
sensorNumber: item.sensor_number,
minDiameter: item.min_diameter,
user: item.username,
create_time: item.create_time,
sensorLocation: item.sensor_location,
})),
);
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
id: item.id,
schemeName: item.scheme_name,
sensorNumber: item.sensor_number,
minDiameter: item.min_diameter,
user: item.username,
create_time: item.create_time,
sensorLocation: item.sensor_location,
}));
setSchemes(nextSchemes);
if (filteredResults.length === 0) {
open?.({
@@ -1,414 +0,0 @@
import React, { useEffect, useCallback, useState, useRef } from "react";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import Style from "ol/style/Style";
import Fill from "ol/style/Fill";
import { Stroke } from "ol/style";
import GeoJson from "ol/format/GeoJSON";
import config from "@config/config";
import { useMap } from "@app/OlMap/MapComponent";
interface PropertyItem {
key: string;
value: string | number | boolean;
label?: string;
}
interface ZonePropsPanelProps {
title?: string;
isVisible?: boolean;
onClose?: () => void;
}
const ZonePropsPanel: React.FC<ZonePropsPanelProps> = ({
title = "分区属性信息",
isVisible = true,
onClose,
}) => {
const map = useMap();
const [props, setProps] = React.useState<
PropertyItem[] | Record<string, any>
>({});
const [highlightedFeature, setHighlightedFeature] = useState<any>(null);
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const handleMapClickSelectFeatures = useCallback(
(pixel: number[]) => {
if (!map || !highlightLayerRef.current) return;
let clickedFeature: any = null;
map.forEachFeatureAtPixel(pixel, (feature) => {
if (!clickedFeature) {
clickedFeature = feature;
}
});
if (clickedFeature) {
const layer = clickedFeature?.getId()?.toString().split(".")[0];
if (layer !== "network_zone") {
return;
}
setHighlightedFeature(clickedFeature);
setProps(clickedFeature.getProperties());
// 更新高亮图层
const source = highlightLayerRef.current.getSource();
source?.clear();
source?.addFeature(clickedFeature);
} else {
setHighlightedFeature(null);
setProps({});
// 清空高亮图层
const source = highlightLayerRef.current.getSource();
source?.clear();
}
},
[map]
);
// 将 properties 转换为统一格式
const formatProperties = (
props: PropertyItem[] | Record<string, any>
): PropertyItem[] => {
if (Array.isArray(props)) {
return props.filter((item) => !shouldHideProperty(item.key));
}
return Object.entries(props)
.filter(([key]) => !shouldHideProperty(key))
.map(([key, value]) => ({
key,
value,
label: getChineseLabel(key),
}));
};
// 判断是否应该隐藏某个属性
const shouldHideProperty = (key: string): boolean => {
const hiddenKeys = [
"id",
"geometry",
"Note1",
"Note3",
"Note4",
"Note5",
"Note6",
"Note7",
"Note8",
"Note9",
"Note10",
];
return hiddenKeys.includes(key);
};
useEffect(() => {
if (!map) {
return;
}
const networkZoneLayer = new VectorLayer({
source: new VectorSource({
url: `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}:network_zone&outputFormat=application/json`,
format: new GeoJson(),
}),
style: new Style({
fill: new Fill({
color: "rgba(255, 255, 255, 0)",
}),
stroke: new Stroke({
color: "#e01414ff",
width: 5,
}),
}),
properties: {
name: "管网分区",
value: "network_zone",
},
});
map.addLayer(networkZoneLayer);
// 创建高亮图层
const highlightLayer = new VectorLayer({
source: new VectorSource(),
style: new Style({
fill: new Fill({
color: "rgba(255, 255, 0, 0.3)",
}),
stroke: new Stroke({
color: "#ff0000",
width: 3,
}),
}),
properties: {
name: "高亮分区",
value: "highlight_zone",
},
});
map.addLayer(highlightLayer);
highlightLayerRef.current = highlightLayer;
const clickListener = (evt: any) => {
handleMapClickSelectFeatures(evt.pixel);
};
map.on("click", clickListener);
return () => {
map.removeLayer(networkZoneLayer);
map.removeLayer(highlightLayer);
map.un("click", clickListener);
};
}, [map, handleMapClickSelectFeatures]);
// 获取中文标签
const getChineseLabel = (key: string): string => {
const labelMap: Record<string, string> = {
Id: "ID",
Area: "面积",
Complete: "完成度",
Consumptio: "消耗",
Descriptio: "描述",
FlowError: "流量误差",
Level: "级别",
ModelFlow: "模型流量",
NRW: "无收益水量",
NRWPercent: "无收益水量百分比",
Name: "名称",
Note1: "备注1",
Note2: "备注2",
Note3: "备注3",
Note4: "备注4",
Note5: "备注5",
Note6: "备注6",
Note7: "备注7",
Note8: "备注8",
Note9: "备注9",
Note10: "备注10",
ParentZone: "父区域",
PipeLength: "管道长度",
Population: "人口",
ScadaFlow: "SCADA流量",
Tag: "标签",
TotalFlowE: "总流量误差",
TotalModel: "总模型",
TotalScada: "总SCADA",
WaterConsu: "水消耗",
WaterSuppl: "水供应",
};
return labelMap[key] || key;
};
// 优先使用从store中获取的props,如果没有则使用传入的properties
const dataToShow = props;
const formattedProperties = formatProperties(dataToShow);
// 定义属性的显示顺序
const propertyOrder = [
"Id",
"Name",
"PipeLength",
"ModelFlow",
"Population",
"Level",
"Note2",
"Area",
"Descriptio",
"ParentZone",
"Tag",
"Complete",
"Consumptio",
"FlowError",
"NRW",
"NRWPercent",
"ScadaFlow",
"TotalFlowE",
"TotalModel",
"TotalScada",
"WaterConsu",
"WaterSuppl",
];
// 根据自定义顺序对属性进行排序
const sortedProperties = [...formattedProperties].sort((a, b) => {
const aIndex = propertyOrder.indexOf(a.key);
const bIndex = propertyOrder.indexOf(b.key);
// 如果属性不在排序列表中,则将其放在末尾
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
// 格式化值显示
const formatValue = (value: any, key: string): string => {
if (value === null || value === undefined) {
return "-";
}
if (typeof value === "boolean") {
return value ? "是" : "否";
}
if (typeof value === "string" && value.trim() === "") {
return "-";
}
// 对于特定的数值字段,添加单位
if (typeof value === "number") {
switch (key) {
case "Area":
return `${value.toLocaleString()}`;
case "PipeLength":
return `${value.toLocaleString()} m`;
case "Population":
return `${value.toLocaleString()}`;
case "ModelFlow":
return `${value.toLocaleString()} L/天`;
case "ScadaFlow":
case "TotalModel":
case "TotalScada":
case "WaterConsu":
case "WaterSuppl":
return `${value.toLocaleString()} L/s`;
case "NRWPercent":
return value !== null ? `${value}%` : "-";
default:
return value.toLocaleString();
}
}
return String(value);
};
if (!isVisible) {
return null;
}
const isImportantKeys = ["Name", "Id", "ModelFlow", "Area", "PipeLength"];
return (
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm opacity-95 hover:opacity-100 transition-all duration-300">
{/* 头部 */}
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold">{title}</h3>
</div>
{onClose && (
<button
onClick={onClose}
className="text-white hover:bg-white hover:bg-opacity-20 rounded-full p-1 transition-all duration-200"
aria-label="关闭"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{sortedProperties.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg
className="w-16 h-16 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{sortedProperties.map((item, index) => {
const isImportant = isImportantKeys.includes(item.key);
return (
<div
key={item.key || index}
className={`group rounded-lg p-3 transition-all duration-200 ${
isImportant
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
: "bg-gray-50 hover:bg-gray-100"
}`}
>
<div className="flex justify-between items-start gap-3">
<span
className={`font-medium text-xs uppercase tracking-wide ${
isImportant ? "text-blue-700" : "text-gray-600"
}`}
>
{item.label || item.key}
</span>
<span
className={`text-sm font-semibold text-right flex-1 ${
isImportant ? "text-blue-900" : "text-gray-800"
}`}
>
{formatValue(item.value, item.key)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 底部统计区域 */}
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600 flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{sortedProperties.length}
</span>
{highlightedFeature && (
<span className="text-green-600 flex items-center gap-1 font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
</span>
)}
</div>
</div>
</div>
);
};
export default ZonePropsPanel;
@@ -37,14 +37,15 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import config from "@/config/config";
import { useGetIdentity } from "@refinedev/core";
import { useNotification } from "@refinedev/core";
import axios from "axios";
import { api } from "@/lib/api";
import { apiFetch } from "@/lib/apiFetch";
dayjs.extend(utc);
dayjs.extend(timezone);
type IUser = {
id: number;
name: string;
id: string;
name?: string;
};
export interface TimeSeriesPoint {
@@ -67,6 +68,10 @@ export interface SCADADataPanelProps {
showCleaning?: boolean;
/** 清洗数据的回调 */
onCleanData?: () => void;
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
start_time?: string;
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
end_time?: string;
}
type PanelTab = "chart" | "table";
@@ -96,10 +101,10 @@ const fetchFromBackend = async (
try {
// 优先查询清洗数据和模拟数据
const [cleaningRes, simulationRes] = await Promise.all([
fetch(cleaningDataUrl)
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(simulationDataUrl)
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
@@ -118,7 +123,7 @@ const fetchFromBackend = async (
);
} else {
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
const rawRes = await fetch(rawDataUrl)
const rawRes = await apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
const rawData = transformBackendData(rawRes, deviceIds);
@@ -313,6 +318,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
fractionDigits = 2,
showCleaning = false,
onCleanData,
start_time,
end_time,
}) => {
const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
@@ -338,13 +345,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
const [cleanRes, rawRes, simRes] = await Promise.all([
fetch(cleaningDataUrl)
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(rawDataUrl)
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(simulationDataUrl)
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
@@ -395,8 +402,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
};
}, [showCleaning]);
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
const [to, setTo] = useState<Dayjs>(() => dayjs());
const [from, setFrom] = useState<Dayjs>(() => {
if (start_time) {
const parsedStart = dayjs(start_time);
if (parsedStart.isValid()) {
return parsedStart;
}
}
return dayjs().subtract(1, "day");
});
const [to, setTo] = useState<Dayjs>(() => {
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
return parsedEnd;
}
}
return dayjs();
});
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
@@ -411,10 +434,27 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setActiveTab(defaultTab);
}, [defaultTab]);
useEffect(() => {
if (!start_time && !end_time) return;
if (start_time) {
const parsedStart = dayjs(start_time);
if (parsedStart.isValid()) {
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart));
}
}
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd));
}
}
}, [start_time, end_time]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const hasDevices = deviceIds.length > 0;
const hasData = timeSeries.length > 0;
const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]);
const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
@@ -458,7 +498,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return;
}
if (!user || !user.name) {
if (!user || !user.id) {
open?.({
type: "error",
message: "用户信息无效,请重新登录",
@@ -474,7 +514,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const endTime = dayjs(rangeTo).toISOString();
// 调用后端清洗接口
const response = await axios.post(
const response = await api.post(
`${
config.BACKEND_URL
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join(
@@ -527,7 +567,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
} else {
setTimeSeries([]);
}
}, [deviceIds.join(",")]);
}, [deviceIdsKey, handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
@@ -48,11 +48,12 @@ import {
} from "@mui/icons-material";
import { FixedSizeList } from "react-window";
import { useNotification } from "@refinedev/core";
import axios from "axios";
import { api } from "@/lib/api";
import { useGetIdentity } from "@refinedev/core";
import config, { NETWORK_NAME } from "@/config/config";
import config from "@/config/config";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { useProject } from "@/contexts/ProjectContext";
import { GeoJSON } from "ol/format";
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import VectorLayer from "ol/layer/Vector";
@@ -103,8 +104,8 @@ interface SCADADeviceListProps {
}
type IUser = {
id: number;
name: string;
id: string;
name?: string;
};
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
@@ -180,12 +181,17 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
}
}, [pendingSelection, onSelectionChange]);
// Get workspace from context
const project = useProject();
const workspace = project?.workspace;
// 初始化 SCADA 设备列表
useEffect(() => {
const fetchScadaDevices = async () => {
setLoading(true);
try {
const url = `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}:geo_scada&outputFormat=application/json`;
const activeWorkspace = workspace || config.MAP_WORKSPACE;
const url = `${config.MAP_URL}/${activeWorkspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${activeWorkspace}:geo_scada&outputFormat=application/json`;
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch SCADA devices");
const json = await response.json();
@@ -211,7 +217,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
}
};
fetchScadaDevices();
}, []);
}, [workspace]);
const effectiveDevices = devices.length > 0 ? devices : internalDevices;
@@ -595,7 +601,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
return;
}
if (!user || !user.name) {
if (!user || !user.id) {
open?.({
type: "error",
message: "用户信息无效,请重新登录",
@@ -616,7 +622,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
const endTime = dayjs(cleanEndTime).toISOString();
// 调用后端清洗接口
const response = await axios.post(
const response = await api.post(
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
);
@@ -735,7 +741,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
source.addFeature(feature);
}
});
}, [selectedDeviceIds, highlightFeatures]);
}, [selectedDeviceIds, highlightFeatures, highlightLayer]);
// 清理定时器
useEffect(() => {
return () => {
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import Image from "next/image";
import { useMap } from "../MapComponent";
import TileLayer from "ol/layer/Tile.js";
import XYZ from "ol/source/XYZ.js";
@@ -136,7 +137,7 @@ const BaseLayers: React.FC = () => {
}
layerInfo.layer.setVisible(layerInfo.id === activeId);
});
}, [map]);
}, [map, activeId]);
const changeMapLayers = (id: string) => {
if (map) {
@@ -179,7 +180,7 @@ const BaseLayers: React.FC = () => {
};
return (
<div className="absolute right-17 bottom-8 z-1300">
<div className="absolute right-17 bottom-11 z-20">
<div
className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black"
onMouseEnter={handleEnter}
@@ -187,7 +188,7 @@ const BaseLayers: React.FC = () => {
>
<div className="w-20 h-20 p-1">
<button onClick={() => handleQuickSwitch()}>
<img
<Image
width={240}
height={100}
src={
@@ -200,6 +201,7 @@ const BaseLayers: React.FC = () => {
? baseLayers[1].name
: baseLayers[0].name
}
sizes="72px"
className="object-cover object-left w-18 h-18 rounded-xl"
/>
<div className=" absolute left-1 bottom-1 flex w-18 h-auto items-center justify-center rounded-b-xl text-xs text-white bg-black opacity-80">
@@ -227,11 +229,12 @@ const BaseLayers: React.FC = () => {
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
onClick={() => handleMapLayers(item.id)}
>
<img
<Image
width={240}
height={100}
src={item.img}
alt={item.name}
sizes="64px"
className={clsx(
"object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300",
{
@@ -31,12 +31,15 @@ import { useMap } from "../MapComponent";
const DrawPanel: React.FC = () => {
const map = useMap();
const [activeTool, setActiveTool] = useState<string>("pan");
const [drawLayer, setDrawLayer] = useState<VectorLayer<VectorSource> | null>(
null
);
const drawLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]);
const [historyStack, setHistoryStack] = useState<Feature<Geometry>[][]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [history, setHistory] = useState<{
stack: Feature<Geometry>[][];
index: number;
}>({
stack: [[]],
index: 0,
});
const drawInteractionRef = useRef<Draw | null>(null);
@@ -74,13 +77,14 @@ const DrawPanel: React.FC = () => {
});
map.addLayer(drawVectorLayer);
setDrawLayer(drawVectorLayer);
drawLayerRef.current = drawVectorLayer;
return () => {
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
drawInteractionRef.current = null;
}
drawLayerRef.current = null;
map.removeLayer(drawVectorLayer);
};
}, [map, drawInteractionRef]);
@@ -88,14 +92,16 @@ const DrawPanel: React.FC = () => {
// 保存到历史记录
const saveToHistory = useCallback(
(features: Feature<Geometry>[]) => {
setHistoryStack((prevStack) => {
const newHistory = prevStack.slice(0, historyIndex + 1);
newHistory.push([...features]);
setHistoryIndex(newHistory.length - 1);
return newHistory;
setHistory((prev) => {
const newStack = prev.stack.slice(0, prev.index + 1);
newStack.push([...features]);
return {
stack: newStack,
index: newStack.length - 1,
};
});
},
[historyIndex]
[]
);
// 添加绘图交互
@@ -103,6 +109,7 @@ const DrawPanel: React.FC = () => {
type: GeometryType,
geometryFunction?: GeometryFunction
) => {
const drawLayer = drawLayerRef.current;
if (!drawLayer) return;
if (!map) return;
@@ -153,7 +160,13 @@ const DrawPanel: React.FC = () => {
// 绘图完成事件
draw.on("drawend", (event: DrawEvent) => {
const feature = event.feature;
const currentFeatures = [...drawnFeatures, feature];
const currentFeatures = [...source.getFeatures()];
// Fallback in case feature has not been synced to source yet.
if (!currentFeatures.includes(feature)) {
currentFeatures.push(feature);
}
setDrawnFeatures(currentFeatures);
saveToHistory(currentFeatures);
});
@@ -244,28 +257,35 @@ const DrawPanel: React.FC = () => {
// 撤销功能
const handleUndo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const previousFeatures = historyStack[newIndex];
if (history.index > 0) {
const newIndex = history.index - 1;
const previousFeatures = history.stack[newIndex];
updateDrawLayer(previousFeatures);
setDrawnFeatures(previousFeatures);
setHistoryIndex(newIndex);
setHistory((prev) => ({
...prev,
index: newIndex,
}));
}
};
// 重做功能
const handleRedo = () => {
if (historyIndex < historyStack.length - 1) {
const newIndex = historyIndex + 1;
const nextFeatures = historyStack[newIndex];
if (history.index < history.stack.length - 1) {
const newIndex = history.index + 1;
const nextFeatures = history.stack[newIndex];
updateDrawLayer(nextFeatures);
setDrawnFeatures(nextFeatures);
setHistoryIndex(newIndex);
setHistory((prev) => ({
...prev,
index: newIndex,
}));
}
};
// 删除所有绘制的要素
const handleDelete = () => {
const drawLayer = drawLayerRef.current;
if (!drawLayer) return;
const source = drawLayer.getSource();
@@ -282,6 +302,7 @@ const DrawPanel: React.FC = () => {
// 更新绘图图层
const updateDrawLayer = (features: Feature<Geometry>[]) => {
const drawLayer = drawLayerRef.current;
if (!drawLayer) return;
const source = drawLayer.getSource();
@@ -291,17 +312,9 @@ const DrawPanel: React.FC = () => {
}
};
// 初始化历史记录
useEffect(() => {
// 初始化空的历史记录
if (historyStack.length === 0) {
saveToHistory([]);
}
}, [historyStack.length, saveToHistory]);
// 判断按钮是否应该禁用
const isUndoDisabled = historyIndex <= 0;
const isRedoDisabled = historyIndex >= historyStack.length - 1;
const isUndoDisabled = history.index <= 0;
const isRedoDisabled = history.index >= history.stack.length - 1;
const isDeleteDisabled = drawnFeatures.length === 0;
const isSaveDisabled = drawnFeatures.length === 0;
@@ -34,6 +34,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -58,6 +59,10 @@ export interface SCADADataPanelProps {
defaultTab?: "chart" | "table";
/** Y 轴数值的小数位数 */
fractionDigits?: number;
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
start_time?: string;
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
end_time?: string;
}
type PanelTab = "chart" | "table";
@@ -103,10 +108,10 @@ const fetchFromBackend = async (
if (type === "none") {
// 查询清洗值和监测值
const [cleanedRes, rawRes] = await Promise.all([
fetch(cleanedDataUrl)
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(rawDataUrl)
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
@@ -126,13 +131,13 @@ const fetchFromBackend = async (
} else if (type === "scheme") {
// 查询策略模拟值、清洗值和监测值
const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([
fetch(cleanedDataUrl)
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(rawDataUrl)
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(schemeSimulationDataUrl)
apiFetch(schemeSimulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
@@ -178,13 +183,13 @@ const fetchFromBackend = async (
} else {
// realtime: 查询模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
fetch(cleanedDataUrl)
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(rawDataUrl)
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(simulationDataUrl)
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
@@ -391,10 +396,12 @@ const emptyStateMessages: Record<
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
featureInfos,
type = "none",
scheme_type = "burst_Analysis",
scheme_type = "burst_analysis",
scheme_name,
defaultTab = "chart",
fractionDigits = 2,
start_time,
end_time,
}) => {
// 从 featureInfos 中提取设备 ID 列表
const deviceIds = useMemo(
@@ -402,8 +409,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
[featureInfos]
);
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
const [to, setTo] = useState<Dayjs>(() => dayjs());
const [from, setFrom] = useState<Dayjs>(() => {
if (start_time) {
const parsedStart = dayjs(start_time);
if (parsedStart.isValid()) {
return parsedStart;
}
}
return dayjs().subtract(1, "day");
});
const [to, setTo] = useState<Dayjs>(() => {
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
return parsedEnd;
}
}
return dayjs();
});
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
@@ -417,10 +440,30 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setActiveTab(defaultTab);
}, [defaultTab]);
useEffect(() => {
if (!start_time && !end_time) return;
if (start_time) {
const parsedStart = dayjs(start_time);
if (parsedStart.isValid()) {
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart));
}
}
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd));
}
}
}, [start_time, end_time]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const hasDevices = deviceIds.length > 0;
const hasData = timeSeries.length > 0;
const featureInfosKey = useMemo(
() => JSON.stringify(featureInfos),
[featureInfos]
);
const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits),
@@ -467,7 +510,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
} else {
setTimeSeries([]);
}
}, [JSON.stringify(featureInfos)]);
}, [featureInfosKey, handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
@@ -0,0 +1,161 @@
import React, { useState, useEffect, useMemo } from "react";
import { useData, useMap } from "../MapComponent";
import { Checkbox, FormControlLabel } from "@mui/material";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorLayer from "ol/layer/Vector";
import VectorTileLayer from "ol/layer/VectorTile";
import { DeckLayer } from "@utils/layers";
// 定义统一的图层项接口
interface LayerItem {
id: string;
name: string;
visible: boolean;
type: "ol" | "deck";
layerRef: any; // OpenLayers Layer 实例或 deck.gl layer 对象
}
const LAYER_ORDER = [
"junctions",
"reservoirs",
"tanks",
"pipes",
"pumps",
"valves",
"scada",
"waterflowLayer",
"junctionContourLayer",
];
const LayerControl: React.FC = () => {
const map = useMap();
const data = useData();
const [refreshKey, setRefreshKey] = useState(0);
const deckLayer = data?.deckLayer;
const isContourLayerAvailable = data?.isContourLayerAvailable;
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
const setShowContourLayer = data?.setShowContourLayer;
const layerItems = useMemo(() => {
void refreshKey;
if (!map || !data) return [];
const items: LayerItem[] = [];
map.getLayers().getArray().forEach((layer) => {
if (
layer instanceof WebGLVectorTileLayer ||
layer instanceof VectorTileLayer ||
layer instanceof VectorLayer
) {
const value = layer.get("value");
const name = layer.get("name");
if (value) {
items.push({
id: value,
name: name || value,
visible: layer.getVisible(),
type: "ol",
layerRef: layer,
});
}
}
});
if (deckLayer && deckLayer instanceof DeckLayer) {
deckLayer.getDeckLayers().forEach((layer: any) => {
if (!layer?.id) return;
if (layer.id !== "junctionContourLayer" && layer.id !== "waterflowLayer") {
return;
}
if (
(layer.id === "junctionContourLayer" && !isContourLayerAvailable) ||
(layer.id === "waterflowLayer" && !isWaterflowLayerAvailable)
) {
return;
}
items.push({
id: layer.props.id,
name: layer.props.name,
visible:
deckLayer.getDeckLayerVisible(layer.id) ?? layer.props?.visible ?? true,
type: "deck",
layerRef: layer,
});
});
}
return items
.filter((item) => LAYER_ORDER.includes(item.id))
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
}, [
map,
data,
deckLayer,
isContourLayerAvailable,
isWaterflowLayerAvailable,
refreshKey,
]);
useEffect(() => {
if (!map) return;
const layerCollection = map.getLayers();
const handleLayerChange = () => {
setRefreshKey((prev) => prev + 1);
};
layerCollection.on("change:length", handleLayerChange);
return () => {
map.getLayers().un("change:length", handleLayerChange);
};
}, [map]);
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
if (item.type === "ol") {
item.layerRef.setVisible(checked);
} else if (item.type === "deck" && deckLayer) {
if (item.id === "junctionContourLayer") {
setShowContourLayer && setShowContourLayer(checked);
}
if (item.id === "waterflowLayer") {
setShowWaterflowLayer && setShowWaterflowLayer(checked);
}
}
setRefreshKey((prev) => prev + 1);
};
if (!data) {
return <div>Loading...</div>;
}
return (
<div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg z-20 opacity-85 hover:opacity-100 transition-opacity max-w-xs">
<div className="ml-3 grid grid-cols-3">
{layerItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={item.visible}
onChange={(e) => handleVisibilityChange(item, e.target.checked)}
size="small"
/>
}
label={item.name}
sx={{
fontSize: "0.7rem",
"& .MuiFormControlLabel-label": { fontSize: "0.7rem" },
}}
/>
))}
</div>
</div>
);
};
export default LayerControl;
@@ -0,0 +1,88 @@
import React, { useEffect, useState, useRef } from "react";
import { useMap } from "../MapComponent";
import { ScaleLine } from "ol/control";
const Scale: React.FC = () => {
const map = useMap();
const [zoomLevel, setZoomLevel] = useState(0);
const [coordinates, setCoordinates] = useState<[number, number]>([0, 0]);
const scaleLineRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!map) return;
const updateZoomLevel = () => {
const zoom = map.getView().getZoom();
setZoomLevel(zoom ?? 0); // 如果 zoom 是 undefined,则使用默认值 0
};
const updateCoordinates = (event: any) => {
const coords = event.coordinate;
const transformedCoords = coords.map((c: number) =>
parseFloat(c.toFixed(4))
);
setCoordinates(transformedCoords);
};
map.on("moveend", updateZoomLevel);
map.on("pointermove", updateCoordinates);
// Initialize values
updateZoomLevel();
// ScaleLine control
const scaleControl = new ScaleLine({
target: scaleLineRef.current || undefined,
units: "metric",
bar: false,
steps: 4,
text: true,
minWidth: 64,
});
map.addControl(scaleControl);
return () => {
map.un("moveend", updateZoomLevel);
map.un("pointermove", updateCoordinates);
map.removeControl(scaleControl);
};
}, [map]);
return (
<>
<style>
{`
.custom-scale-line .ol-scale-line {
position: static;
background: transparent;
padding: 0;
}
.custom-scale-line .ol-scale-line-inner {
border: 1px solid #475569;
border-top: none;
color: #334155;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.3s;
}
`}
</style>
<div className="absolute bottom-0 right-0 flex items-center gap-2 px-3 py-1.5 bg-white/90 hover:bg-white rounded-tl-xl shadow-lg backdrop-blur-sm text-xs font-medium text-slate-700 z-20 transition-all duration-300 pointer-events-auto">
<div
ref={scaleLineRef}
className="custom-scale-line flex items-center justify-center min-w-[60px]"
/>
<div className="h-3 w-px bg-slate-300 mx-1" />
<div className="min-w-[60px] text-center">
: {zoomLevel.toFixed(1)}
</div>
<div className="h-3 w-px bg-slate-300 mx-1" />
<div className="tabular-nums min-w-[140px] text-center">
: {coordinates[0]}, {coordinates[1]}
</div>
</div>
</>
);
};
export default Scale;
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
// 导入Material-UI图标和组件
import ColorLensIcon from "@mui/icons-material/ColorLens";
@@ -180,26 +180,21 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
}) => {
const map = useMap();
const data = useData();
if (!data) {
return <div>Loading...</div>; // 或其他占位符
}
const {
currentJunctionCalData,
currentPipeCalData,
junctionText,
pipeText,
setShowJunctionTextLayer,
setShowPipeTextLayer,
setShowJunctionId,
setShowPipeId,
setContourLayerAvailable,
setWaterflowLayerAvailable,
setJunctionText,
setPipeText,
setContours,
diameterRange,
elevationRange,
} = data;
const currentJunctionCalData = data?.currentJunctionCalData;
const currentPipeCalData = data?.currentPipeCalData;
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
const setShowPipeTextLayer = data?.setShowPipeTextLayer;
const setShowJunctionId = data?.setShowJunctionId;
const setShowPipeId = data?.setShowPipeId;
const setContourLayerAvailable = data?.setContourLayerAvailable;
const setWaterflowLayerAvailable = data?.setWaterflowLayerAvailable;
const setJunctionText = data?.setJunctionText;
const setPipeText = data?.setPipeText;
const setContours = data?.setContours;
const diameterRange = data?.diameterRange;
const elevationRange = data?.elevationRange;
const unitHeadlossRange = [0, 5];
@@ -213,9 +208,6 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
const [renderLayers, setRenderLayers] = useState<WebGLVectorTileLayer[]>([]);
const [selectedRenderLayer, setSelectedRenderLayer] =
useState<WebGLVectorTileLayer>();
const [availableProperties, setAvailableProperties] = useState<
{ name: string; value: string }[]
>([]);
const [styleConfig, setStyleConfig] = useState<StyleConfig>({
property: "",
classificationMethod: "pretty_breaks",
@@ -237,6 +229,74 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
customColors: [],
});
const getDefaultCustomColors = (
segments: number,
existingColors: string[] = []
) => {
const nextColors = [...existingColors];
const baseColors = RAINBOW_PALETTES[0].colors;
while (nextColors.length < segments) {
nextColors.push(baseColors[nextColors.length % baseColors.length]);
}
return nextColors.slice(0, segments);
};
const getDefaultCustomBreaks = (
segments: number,
property: string,
layer: WebGLVectorTileLayer | undefined = selectedRenderLayer
) => {
if (!layer || !property) {
return Array.from({ length: segments }, () => 0);
}
const selectedLayerId = layer.get("value");
let dataArr: number[] = [];
const isElevation =
selectedLayerId === "junctions" && property === "elevation";
const isDiameter = selectedLayerId === "pipes" && property === "diameter";
if (isElevation && elevationRange) {
dataArr = [elevationRange[0], elevationRange[1]];
} else if (isDiameter && diameterRange) {
dataArr = [diameterRange[0], diameterRange[1]];
} else if (selectedLayerId === "junctions" && currentJunctionCalData) {
dataArr = currentJunctionCalData.map((d: any) => d.value);
} else if (selectedLayerId === "pipes" && currentPipeCalData) {
dataArr = currentPipeCalData.map((d: any) => d.value);
}
if (dataArr.length === 0) {
return Array.from({ length: segments }, () => 0);
}
const defaultBreaks = calculateClassification(
dataArr,
segments,
"pretty_breaks"
).slice(0, segments);
while (defaultBreaks.length < segments) {
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
}
return defaultBreaks;
};
const availableProperties = useMemo<{ name: string; value: string }[]>(() => {
if (!selectedRenderLayer) {
return [];
}
return (selectedRenderLayer.get("properties") || []) as {
name: string;
value: string;
}[];
}, [selectedRenderLayer]);
// 根据分段数生成相应数量的渐进颜色
const generateGradientColors = useCallback(
(segments: number): string[] => {
@@ -261,7 +321,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
}
return colors;
},
[styleConfig.gradientPaletteIndex, parseColor]
[styleConfig.gradientPaletteIndex]
);
// 根据分段数生成彩虹色
@@ -278,63 +338,56 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
[styleConfig.rainbowPaletteIndex]
);
// 保存当前图层的样式状态
const saveLayerStyle = useCallback(
(
layerId?: string,
newLegendConfig?: LegendStyleConfig,
overrideStyleConfig?: StyleConfig
) => {
const currentStyleConfig = overrideStyleConfig || styleConfig;
const saveLayerStyle = (
layerId?: string,
newLegendConfig?: LegendStyleConfig,
overrideStyleConfig?: StyleConfig
) => {
const currentStyleConfig = overrideStyleConfig || styleConfig;
if (!currentStyleConfig.property) {
console.warn("无法保存样式:缺少必要的图层或样式配置");
return;
if (!currentStyleConfig.property) {
console.warn("无法保存样式:缺少必要的图层或样式配置");
return;
}
if (!layerId) return;
const layerName =
newLegendConfig?.layerName ||
selectedRenderLayer?.get("name") ||
`图层${layerId}`;
const property = availableProperties.find(
(p) => p.value === currentStyleConfig.property
);
const legendConfig: LegendStyleConfig = newLegendConfig || {
layerId,
layerName,
property: property?.name || currentStyleConfig.property,
colors: [],
type: selectedRenderLayer?.get("type") || "point",
dimensions: [],
breaks: [],
};
const newStyleState: LayerStyleState = {
layerId,
layerName,
styleConfig: { ...currentStyleConfig },
legendConfig: { ...legendConfig },
isActive: true,
};
setLayerStyleStates((prev) => {
const existingIndex = prev.findIndex((state) => state.layerId === layerId);
if (existingIndex !== -1) {
const updated = [...prev];
updated[existingIndex] = newStyleState;
return updated;
}
if (!layerId) return; // 如果没有传入 layerId,则不保存
// 如果没有传入图例配置,则创建一个默认的空配置
const layerName =
newLegendConfig?.layerName ||
selectedRenderLayer?.get("name") ||
`图层${layerId}`;
const property = availableProperties.find(
(p) => p.value === currentStyleConfig.property
);
let legendConfig: LegendStyleConfig = newLegendConfig || {
layerId,
layerName,
property: property?.name || currentStyleConfig.property,
colors: [],
type: selectedRenderLayer?.get("type") || "point",
dimensions: [],
breaks: [],
};
const newStyleState: LayerStyleState = {
layerId,
layerName,
styleConfig: { ...currentStyleConfig },
legendConfig: { ...legendConfig },
isActive: true,
};
setLayerStyleStates((prev) => {
// 检查是否已存在该图层的样式状态
const existingIndex = prev.findIndex(
(state) => state.layerId === layerId
);
if (existingIndex !== -1) {
// 更新已存在的状态
const updated = [...prev];
updated[existingIndex] = newStyleState;
return updated;
} else {
// 添加新的状态
return [...prev, newStyleState];
}
});
},
[selectedRenderLayer, styleConfig, availableProperties]
);
return [...prev, newStyleState];
});
};
// 设置分类样式参数,触发样式应用
const setStyleState = () => {
if (!selectedRenderLayer) return;
@@ -787,7 +840,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
};
// 重置样式
const resetStyle = useCallback(() => {
const resetStyle = () => {
if (!selectedRenderLayer) return;
// 重置 WebGL 图层样式
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
@@ -815,7 +868,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
setWaterflowLayerAvailable && setWaterflowLayerAvailable(false);
}
}
}, [selectedRenderLayer]);
};
// 更新当前 VectorTileSource 中的所有缓冲要素属性
const updateVectorTileSource = (property: string, data: any[]) => {
if (!map) return;
@@ -857,7 +910,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
});
};
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
const [tileLoadListeners, setTileLoadListeners] = useState<
const tileLoadListenersRef = useRef<
Map<VectorTileSource, (event: any) => void>
>(new Map());
@@ -879,8 +932,6 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
dataMap.set(d.ID, d.value || 0);
});
// 新增监听器并保存
const newListeners = new Map<VectorTileSource, (event: any) => void>();
const listener = (event: any) => {
try {
if (event.tile instanceof VectorTile) {
@@ -906,8 +957,7 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
};
vectorTileSource.on("tileloadend", listener);
newListeners.set(vectorTileSource, listener);
setTileLoadListeners(newListeners);
tileLoadListenersRef.current.set(vectorTileSource, listener);
};
// 新增函数:取消对应 layerId 已添加的 on 事件
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
@@ -918,14 +968,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
.map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source)[0];
if (!vectorTileSource) return;
const listener = tileLoadListeners.get(vectorTileSource);
const listener = tileLoadListenersRef.current.get(vectorTileSource);
if (listener) {
vectorTileSource.un("tileloadend", listener);
setTileLoadListeners((prev) => {
const newMap = new Map(prev);
newMap.delete(vectorTileSource);
return newMap;
});
tileLoadListenersRef.current.delete(vectorTileSource);
}
};
@@ -1019,6 +1065,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
if (!applyPipeStyle) {
removeVectorTileSourceLoadedEvent("pipes");
}
// This effect is intentionally driven by explicit style triggers and data snapshots.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
styleUpdateTrigger,
applyJunctionStyle,
@@ -1044,117 +1092,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
updateVisibleLayers();
}, [map]);
// 获取选中图层的属性,并检查是否有已缓存的样式状态
useEffect(() => {
// 如果没有矢量图层或没有选中图层,清空属性列表
if (!renderLayers || renderLayers.length === 0) {
setAvailableProperties([]);
return;
}
// 如果没有选中图层,清空属性列表
if (!selectedRenderLayer) {
setAvailableProperties([]);
return;
}
// 获取第一个要素的数值型属性
const properties = selectedRenderLayer.get("properties") || {};
setAvailableProperties(properties);
// 设置选中的渲染图层
const renderLayer = renderLayers.filter((layer) => {
return layer.get("value") === selectedRenderLayer?.get("value");
})[0];
setSelectedRenderLayer(renderLayer);
// 检查是否有已缓存的样式状态,如果有则自动恢复
const layerId = selectedRenderLayer.get("value");
const cachedStyleState = layerStyleStates.find(
(state) => state.layerId === layerId
);
if (cachedStyleState) {
setStyleConfig(cachedStyleState.styleConfig);
}
}, [renderLayers, selectedRenderLayer, map, renderLayers, layerStyleStates]);
// 监听颜色类型变化,当切换到单一色时自动勾选宽度调整选项
useEffect(() => {
if (styleConfig.colorType === "single") {
setStyleConfig((prev) => ({
...prev,
adjustWidthByProperty: true,
}));
}
}, [styleConfig.colorType]);
// 初始化或调整自定义断点数组长度,默认使用 pretty_breaks 生成若存在数据
useEffect(() => {
if (styleConfig.classificationMethod !== "custom_breaks") return;
const numBreaks = styleConfig.segments;
setStyleConfig((prev) => {
const prevBreaks = prev.customBreaks || [];
if (prevBreaks.length === numBreaks) return prev;
const selectedLayerId = selectedRenderLayer?.get("value");
let dataArr: number[] = [];
const isElevation =
selectedLayerId === "junctions" && styleConfig.property === "elevation";
const isDiameter =
selectedLayerId === "pipes" && styleConfig.property === "diameter";
if (isElevation && elevationRange) {
dataArr = [elevationRange[0], elevationRange[1]];
} else if (isDiameter && diameterRange) {
dataArr = [diameterRange[0], diameterRange[1]];
} else if (selectedLayerId === "junctions" && currentJunctionCalData) {
dataArr = currentJunctionCalData.map((d: any) => d.value);
} else if (selectedLayerId === "pipes" && currentPipeCalData) {
dataArr = currentPipeCalData.map((d: any) => d.value);
}
let defaultBreaks: number[] = Array.from({ length: numBreaks }, () => 0);
if (dataArr && dataArr.length > 0) {
defaultBreaks = calculateClassification(
dataArr,
styleConfig.segments,
"pretty_breaks"
);
defaultBreaks = defaultBreaks.slice(0, numBreaks);
if (defaultBreaks.length < numBreaks)
while (defaultBreaks.length < numBreaks)
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
}
return { ...prev, customBreaks: defaultBreaks };
});
}, [
styleConfig.classificationMethod,
styleConfig.segments,
styleConfig.property,
selectedRenderLayer,
currentJunctionCalData,
currentPipeCalData,
elevationRange,
diameterRange,
]);
// 初始化或调整自定义颜色数组长度
useEffect(() => {
const numColors = styleConfig.segments;
setStyleConfig((prev) => {
const prevColors = prev.customColors || [];
if (prevColors.length === numColors) return prev;
const newColors = [...prevColors];
const baseColors = RAINBOW_PALETTES[0].colors;
while (newColors.length < numColors) {
newColors.push(baseColors[newColors.length % baseColors.length]);
}
return { ...prev, customColors: newColors.slice(0, numColors) };
});
}, [styleConfig.segments]);
if (!data) {
return <div>Loading...</div>;
}
const getColorSetting = () => {
if (styleConfig.colorType === "single") {
@@ -1624,9 +1564,21 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
const cachedStyleState = layerStyleStates.find(
(state) => state.layerId === layerId
);
// 只有在没有缓存时才清空属性
if (!cachedStyleState) {
setStyleConfig((prev) => ({ ...prev, property: "" }));
if (cachedStyleState) {
setStyleConfig(cachedStyleState.styleConfig);
} else {
setStyleConfig((prev) => ({
...prev,
property: "",
customBreaks:
prev.classificationMethod === "custom_breaks"
? getDefaultCustomBreaks(prev.segments, "", newLayer)
: prev.customBreaks,
customColors: getDefaultCustomColors(
prev.segments,
prev.customColors
),
}));
}
}
}}
@@ -1647,7 +1599,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
<Select
value={styleConfig.property}
onChange={(e) => {
setStyleConfig((prev) => ({ ...prev, property: e.target.value }));
const nextProperty = e.target.value;
setStyleConfig((prev) => ({
...prev,
property: nextProperty,
customBreaks:
prev.classificationMethod === "custom_breaks"
? getDefaultCustomBreaks(prev.segments, nextProperty)
: prev.customBreaks,
}));
}}
disabled={!selectedRenderLayer}
>
@@ -1664,9 +1624,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
<Select
value={styleConfig.classificationMethod}
onChange={(e) => {
const nextMethod = e.target.value;
setStyleConfig((prev) => ({
...prev,
classificationMethod: e.target.value,
classificationMethod: nextMethod,
customBreaks:
nextMethod === "custom_breaks"
? getDefaultCustomBreaks(prev.segments, prev.property)
: prev.customBreaks,
}));
}}
>
@@ -1695,7 +1660,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
return {
...prev,
segments: newSegments,
customColors: newCustomColors,
customBreaks:
prev.classificationMethod === "custom_breaks"
? getDefaultCustomBreaks(newSegments, prev.property)
: prev.customBreaks,
customColors: getDefaultCustomColors(
newSegments,
newCustomColors
),
};
})
}
@@ -1782,6 +1754,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
return {
...prev,
colorType: newColorType,
adjustWidthByProperty:
newColorType === "single"
? true
: prev.adjustWidthByProperty,
customColors: newCustomColors,
};
});
@@ -10,6 +10,9 @@ interface LegendStyleConfig {
type: string; // 图例类型
dimensions: number[]; // 尺寸大小
breaks: number[]; // 分段值
labels?: string[]; // 可选标签(用于离散分类)
columns?: number;
itemsPerColumn?: number;
}
// 图例组件
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
@@ -24,6 +27,9 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
type, // 图例类型
dimensions,
breaks,
labels,
columns = 1,
itemsPerColumn,
}) => {
return (
<Box
@@ -33,9 +39,26 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
<Typography variant="subtitle2" gutterBottom>
{layerName} - {property}
</Typography>
{[...Array(breaks.length)].map((_, index) => {
const color = colors[index]; // 默认颜色为黑色
const dimension = dimensions[index]; // 默认尺寸为16
<Box
sx={{
display: "grid",
gridTemplateColumns:
itemsPerColumn && itemsPerColumn > 0
? undefined
: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
gridTemplateRows:
itemsPerColumn && itemsPerColumn > 0
? `repeat(${itemsPerColumn}, minmax(0, auto))`
: undefined,
gridAutoFlow:
itemsPerColumn && itemsPerColumn > 0 ? "column" : undefined,
columnGap: 1.5,
rowGap: 0.5,
}}
>
{[...Array(breaks.length)].map((_, index) => {
const color = colors[index]; // 默认颜色为黑色
const dimension = dimensions[index]; // 默认尺寸为16
// // 处理第一个区间(小于 breaks[0])
// if (index === 0) {
@@ -66,37 +89,39 @@ const StyleLegend: React.FC<LegendStyleConfig> = ({
// }
// 处理中间区间(breaks[index] - breaks[index + 1]
if (index + 1 < breaks.length) {
const prevValue = breaks[index];
const currentValue = breaks[index + 1];
return (
<Box key={index} className="flex items-center gap-2 mb-1">
<Box
sx={
type === "point"
? {
width: dimension,
height: dimension,
borderRadius: "50%",
backgroundColor: color,
}
: {
width: 16,
height: dimension,
backgroundColor: color,
border: `1px solid ${color}`,
}
}
/>
<Typography variant="caption" className="text-xs">
{prevValue?.toFixed(1)} - {currentValue?.toFixed(1)}
</Typography>
</Box>
);
}
if (index + 1 < breaks.length) {
const prevValue = breaks[index];
const currentValue = breaks[index + 1];
return (
<Box key={index} className="flex items-center gap-2">
<Box
sx={
type === "point"
? {
width: dimension,
height: dimension,
borderRadius: "50%",
backgroundColor: color,
}
: {
width: 16,
height: dimension,
backgroundColor: color,
border: `1px solid ${color}`,
}
}
/>
<Typography variant="caption" className="text-xs">
{labels?.[index] ??
`${prevValue?.toFixed(1)} - ${currentValue?.toFixed(1)}`}
</Typography>
</Box>
);
}
return null;
})}
return null;
})}
</Box>
</Box>
);
};
@@ -28,6 +28,7 @@ import { TbRewindBackward15, TbRewindForward15 } from "react-icons/tb";
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { useData } from "../MapComponent";
import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useMap } from "../MapComponent";
interface TimelineProps {
@@ -38,37 +39,33 @@ interface TimelineProps {
schemeType?: string;
}
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
const Timeline: React.FC<TimelineProps> = ({
schemeDate,
timeRange,
disableDateSelection = false,
schemeName = "",
schemeType = "burst_Analysis",
schemeType = "burst_analysis",
}) => {
const data = useData();
if (!data) {
return <div>Loading...</div>; // 或其他占位符
}
const {
currentTime,
setCurrentTime,
selectedDate,
setSelectedDate,
setCurrentJunctionCalData,
setCurrentPipeCalData,
junctionText,
pipeText,
} = data;
if (
setCurrentTime === undefined ||
currentTime === undefined ||
selectedDate === undefined ||
setSelectedDate === undefined
) {
return <div>Loading...</div>; // 或其他占位符
}
const fallbackSelectedDateRef = useRef(new Date());
const hasTimelineState =
data &&
data.setCurrentTime !== undefined &&
data.currentTime !== undefined &&
data.selectedDate !== undefined &&
data.setSelectedDate !== undefined;
const currentTime = data?.currentTime ?? -1;
const setCurrentTime = data?.setCurrentTime ?? NOOP_SET_CURRENT_TIME;
const selectedDate = data?.selectedDate ?? fallbackSelectedDateRef.current;
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const { open } = useNotification();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟
@@ -85,7 +82,7 @@ const Timeline: React.FC<TimelineProps> = ({
if (schemeDate) {
setSelectedDate(schemeDate);
}
}, [schemeDate]);
}, [schemeDate, setSelectedDate]);
// 新增:用于 Draggable 的 nodeRef
const draggableRef = useRef<HTMLDivElement>(null);
@@ -97,7 +94,20 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const fetchFrameData = async (
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => {
if (setCurrentJunctionCalData) {
setCurrentJunctionCalData(nodeResults);
} else {
console.log("setCurrentJunctionCalData is undefined");
}
if (setCurrentPipeCalData) {
setCurrentPipeCalData(linkResults);
} else {
console.log("setCurrentPipeCalData is undefined");
}
}, [setCurrentJunctionCalData, setCurrentPipeCalData]);
const fetchFrameData = useCallback(async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
@@ -117,11 +127,11 @@ const Timeline: React.FC<TimelineProps> = ({
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else {
disableDateSelection && schemeName
? (nodePromise = fetch(
? (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
))
: (nodePromise = fetch(
: (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
));
@@ -138,11 +148,11 @@ const Timeline: React.FC<TimelineProps> = ({
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else {
disableDateSelection && schemeName
? (linkPromise = fetch(
? (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`,
))
: (linkPromise = fetch(
: (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
));
@@ -177,21 +187,7 @@ const Timeline: React.FC<TimelineProps> = ({
}
// 更新状态
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
};
// 提取更新状态的逻辑
const updateDataStates = (nodeResults: any[], linkResults: any[]) => {
if (setCurrentJunctionCalData) {
setCurrentJunctionCalData(nodeResults);
} else {
console.log("setCurrentJunctionCalData is undefined");
}
if (setCurrentPipeCalData) {
setCurrentPipeCalData(linkResults);
} else {
console.log("setCurrentPipeCalData is undefined");
}
};
}, [disableDateSelection, updateDataStates]);
// 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
@@ -248,7 +244,7 @@ const Timeline: React.FC<TimelineProps> = ({
setCurrentTime(value);
}, 500); // 500ms 防抖延迟
},
[timeRange, minTime, maxTime],
[timeRange, minTime, maxTime, setCurrentTime],
);
// 播放控制
@@ -275,7 +271,7 @@ const Timeline: React.FC<TimelineProps> = ({
});
}, playInterval);
}
}, [isPlaying, playInterval]);
}, [isPlaying, playInterval, timeRange, maxTime, minTime, setCurrentTime]);
const handlePause = useCallback(() => {
setIsPlaying(false);
@@ -295,7 +291,7 @@ const Timeline: React.FC<TimelineProps> = ({
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
}, [setCurrentTime]);
// 步进控制
const handleDayStepBackward = useCallback(() => {
@@ -304,14 +300,14 @@ const Timeline: React.FC<TimelineProps> = ({
newDate.setDate(newDate.getDate() - 1);
return newDate;
});
}, []);
}, [setSelectedDate]);
const handleDayStepForward = useCallback(() => {
setSelectedDate((prev) => {
const newDate = new Date(prev);
newDate.setDate(newDate.getDate() + 1);
return newDate;
});
}, []);
}, [setSelectedDate]);
const handleStepBackward = useCallback(() => {
setCurrentTime((prev) => {
let next = prev - 15;
@@ -322,7 +318,7 @@ const Timeline: React.FC<TimelineProps> = ({
}
return next;
});
}, [timeRange, minTime, maxTime]);
}, [timeRange, minTime, maxTime, setCurrentTime]);
const handleStepForward = useCallback(() => {
setCurrentTime((prev) => {
@@ -334,14 +330,14 @@ const Timeline: React.FC<TimelineProps> = ({
}
return next;
});
}, [timeRange, minTime, maxTime]);
}, [timeRange, minTime, maxTime, setCurrentTime]);
// 日期选择处理
const handleDateChange = useCallback((newDate: Date | null) => {
if (newDate) {
setSelectedDate(newDate);
}
}, []);
}, [setSelectedDate]);
// 播放间隔改变处理
const handleIntervalChange = useCallback(
@@ -365,7 +361,7 @@ const Timeline: React.FC<TimelineProps> = ({
}, newInterval);
}
},
[isPlaying],
[isPlaying, timeRange, maxTime, minTime, setCurrentTime],
);
// 计算时间段改变处理
const handleCalculatedIntervalChange = useCallback((event: any) => {
@@ -396,6 +392,7 @@ const Timeline: React.FC<TimelineProps> = ({
);
}
}, [
fetchFrameData,
junctionText,
pipeText,
currentTime,
@@ -421,14 +418,14 @@ const Timeline: React.FC<TimelineProps> = ({
clearTimeout(debounceRef.current);
}
};
}, []);
}, [setCurrentTime]);
// 当 timeRange 改变时,设置 currentTime 到 minTime
useEffect(() => {
if (timeRange) {
setCurrentTime(minTime);
}
}, [timeRange, minTime]);
}, [timeRange, minTime, setCurrentTime]);
// 获取地图实例
const map = useMap();
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
@@ -513,7 +510,7 @@ const Timeline: React.FC<TimelineProps> = ({
duration: calculatedInterval,
};
const response = await fetch(
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
{
method: "POST",
@@ -548,6 +545,10 @@ const Timeline: React.FC<TimelineProps> = ({
}
};
if (!hasTimelineState) {
return <div>Loading...</div>;
}
return (
<Draggable nodeRef={draggableRef} handle=".drag-handle">
<div
@@ -604,14 +605,16 @@ const Timeline: React.FC<TimelineProps> = ({
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
>
<Tooltip title="后退一天">
<IconButton
color="primary"
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
>
<FiSkipBack />
</IconButton>
<span>
<IconButton
color="primary"
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
>
<FiSkipBack />
</IconButton>
</span>
</Tooltip>
{/* 日期选择器 */}
<DatePicker
@@ -632,17 +635,20 @@ const Timeline: React.FC<TimelineProps> = ({
disabled={disableDateSelection}
/>
<Tooltip title="前进一天">
<IconButton
color="primary"
onClick={handleDayStepForward}
size="small"
disabled={
disableDateSelection ||
selectedDate.toDateString() === new Date().toDateString()
}
>
<FiSkipForward />
</IconButton>
<span>
<IconButton
color="primary"
onClick={handleDayStepForward}
size="small"
disabled={
disableDateSelection ||
selectedDate.toDateString() ===
new Date().toDateString()
}
>
<FiSkipForward />
</IconButton>
</span>
</Tooltip>
{/* 播放控制按钮 */}
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
@@ -8,35 +8,42 @@ import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import { Style, Stroke, Fill, Circle } from "ol/style";
import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import Point from "ol/geom/Point";
import { bbox, featureCollection } from "@turf/turf";
import StyleEditorPanel from "./StyleEditorPanel";
import { LayerStyleState } from "./StyleEditorPanel";
import StyleLegend from "./StyleLegend"; // 引入图例组件
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService";
import { useNotification } from "@refinedev/core";
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
import { config } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
// 添加接口定义隐藏按钮的props
interface ToolbarProps {
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
queryType?: string; // 可选的查询类型参数
schemeType?: string; // 可选的方案类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
}
const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons,
queryType,
schemeType,
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);
@@ -45,6 +52,106 @@ const Toolbar: React.FC<ToolbarProps> = ({
const [showHistoryPanel, setShowHistoryPanel] = useState<boolean>(false);
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName;
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
[string, string][] | null
>(null);
const [chatPanelType, setChatPanelType] = useState<
"realtime" | "scheme" | "none"
>("none");
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
startTime?: string;
endTime?: string;
} | null>(null);
// Wire up chat tool actions (locate, view_history, view_scada)
useChatToolActionHandler(
useCallback(
(action) => {
const geojsonFormat = new GeoJSON();
const zoomToFeatures = (
features: Feature[],
geometryKind: "point" | "line",
) => {
if (features.length === 0) return;
if (geometryKind === "point" && features.length === 1) {
const geometry = features[0].getGeometry();
if (geometry instanceof Point) {
map?.getView().animate({
center: geometry.getCoordinates(),
zoom: 18,
duration: 1000,
});
return;
}
}
const geojsonFeatures = features.map((f) =>
geojsonFormat.writeFeatureObject(f),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
padding: geometryKind === "line" ? [60, 60, 60, 60] : [40, 40, 40, 40],
});
}
};
const locateFeatures = (
ids: string[],
layer: string,
geometryKind: "point" | "line",
) => {
queryFeaturesByIds(ids, layer).then((features) => {
if (features.length > 0) {
setHighlightFeatures(features);
zoomToFeatures(features, geometryKind);
}
});
};
switch (action.type) {
case "locate_features": {
locateFeatures(action.ids, action.layer, action.geometryKind);
break;
}
case "view_history": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType(action.dataType);
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
break;
}
case "view_scada": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType("none");
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
setActiveTools((prev) => {
if (prev.includes("history")) {
return prev;
}
return [...prev, "history"];
});
break;
}
}
},
[map],
),
);
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
@@ -323,6 +430,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
case "history":
setShowHistoryPanel(false);
setHighlightFeatures([]);
setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null);
break;
}
};
@@ -349,6 +458,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
setHighlightFeatures([]);
setShowDrawPanel(false);
setShowHistoryPanel(false);
setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null);
// 样式编辑器保持其当前状态,不自动关闭
};
const [computedProperties, setComputedProperties] = useState<
@@ -386,12 +497,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
let response;
if (queryType === "scheme") {
response = await fetch(
response = await apiFetch(
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
);
} else {
response = await fetch(
response = await apiFetch(
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`,
);
@@ -400,7 +511,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
throw new Error("API request failed");
}
const data = await response.json();
setComputedProperties(data.results[0] || {});
if (!data.result || data.result.length === 0) {
setComputedProperties({});
} else {
setComputedProperties(data.result[0] || {});
// console.log("查询到的计算属性:", data.result[0]);
}
} catch (error) {
console.error("Error querying computed properties:", error);
setComputedProperties({});
@@ -408,7 +524,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
};
// 仅当 currentTime 有效时查询
if (currentTime !== -1 && queryType) queryComputedProperties();
}, [highlightFeatures, currentTime, selectedDate]);
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
// 从要素属性中提取属性面板需要的数据
const getFeatureProperties = useCallback(() => {
@@ -418,7 +534,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
const properties = highlightFeature.getProperties();
// 计算属性字段,增加 key 字段
const pipeComputedFields = [
{ key: "flow", label: "流量", unit: "m³/h" },
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "friction", label: "摩阻", unit: "" },
{ key: "headloss", label: "水头损失", unit: "m" },
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
@@ -429,7 +545,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
{ key: "velocity", label: "流速", unit: "m/s" },
];
const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: "m³/h" },
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" },
{ key: "quality", label: "水质", unit: "mg/L" },
@@ -457,6 +573,11 @@ const Toolbar: React.FC<ToolbarProps> = ({
if (computedProperties) {
pipeComputedFields.forEach(({ key, label, unit }) => {
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
if (
key === "unit_headloss" &&
@@ -495,10 +616,11 @@ const Toolbar: React.FC<ToolbarProps> = ({
columns: ["demand", "pattern"],
rows: Array.from({ length: 5 }, (_, i) => i + 1)
.map((idx) => {
const d = properties?.[`demand${idx}`]?.toFixed?.(3);
let d = properties?.[`demand${idx}`];
const p = properties?.[`pattern${idx}`];
// 仅当 demand 有效时展示该行
if (d !== undefined && d !== null && d !== "") {
d = toM3h(Number(d), "lps");
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
}
})
@@ -510,10 +632,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
if (computedProperties) {
nodeComputedFields.forEach(({ key, label, unit }) => {
if (computedProperties[key] !== undefined) {
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties.push({
label,
value:
computedProperties[key].toFixed?.(3) || computedProperties[key],
value?.toFixed?.(3) || value,
unit,
});
}
@@ -701,6 +827,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
return {};
}, [highlightFeatures, computedProperties]);
if (!data) {
return null;
}
return (
<>
<div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity">
@@ -746,9 +876,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
/>
</div>
{showHistoryPanel &&
(HistoryPanel ? (
(chatPanelType === "none" && chatPanelFeatureInfos ? (
<SCADADataPanel
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
visible={showHistoryPanel}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
/>
) : HistoryPanel ? (
<HistoryPanel
featureInfos={(() => {
featureInfos={chatPanelFeatureInfos ?? (() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
@@ -784,13 +921,15 @@ const Toolbar: React.FC<ToolbarProps> = ({
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_Analysis"
scheme_type="burst_analysis"
scheme_name={schemeName}
type={queryType as "realtime" | "scheme" | "none"}
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
/>
) : (
<HistoryDataPanel
featureInfos={(() => {
featureInfos={chatPanelFeatureInfos ?? (() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
@@ -826,9 +965,11 @@ const Toolbar: React.FC<ToolbarProps> = ({
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_Analysis"
scheme_type="burst_analysis"
scheme_name={schemeName}
type={queryType as "realtime" | "scheme" | "none"}
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
/>
))}
@@ -30,7 +30,7 @@ const Zoom: React.FC = () => {
};
return (
<div className="absolute right-4 bottom-8 z-1300">
<div className="absolute right-4 bottom-11 z-20">
<div className="w-8 h-26 flex flex-col gap-2 items-center">
<div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black">
<button
@@ -1,5 +1,6 @@
"use client";
import { config } from "@/config/config";
import { useProject } from "@/contexts/ProjectContext";
import React, {
createContext,
useContext,
@@ -32,6 +33,7 @@ import { Icon, Style } from "ol/style.js";
import { FeatureLike } from "ol/Feature";
import { Point } from "ol/geom";
import { ContourLayer } from "deck.gl";
import { toM3h } from "@utils/units";
interface MapComponentProps {
children?: React.ReactNode;
@@ -75,20 +77,34 @@ interface DataContextType {
const MapContext = createContext<OlMap | undefined>(undefined);
const DataContext = createContext<DataContextType | undefined>(undefined);
const MAP_EXTENT = config.MAP_EXTENT as [number, number, number, number];
const MAP_URL = config.MAP_URL;
const MAP_WORKSPACE = config.MAP_WORKSPACE;
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
// 添加防抖函数
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
type DebouncedFunction<F extends (...args: any[]) => any> = ((
...args: Parameters<F>
) => void) & {
cancel: () => void;
};
function debounce<F extends (...args: any[]) => any>(
func: F,
waitFor: number
): DebouncedFunction<F> {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<F>): void => {
const debounced = (...args: Parameters<F>): void => {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), waitFor);
};
debounced.cancel = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};
return debounced;
}
export const useMap = () => {
@@ -99,8 +115,22 @@ export const useData = () => {
};
const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const project = useProject();
const MAP_WORKSPACE = project?.workspace || config.MAP_WORKSPACE;
const MAP_EXTENT = (project?.extent || config.MAP_EXTENT) as [
number,
number,
number,
number,
];
const MAP_URL = config.MAP_URL;
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const deckLayerRef = useRef<DeckLayer | null>(null);
const isDisposingRef = useRef(false);
const pendingTimeoutsRef = useRef<number[]>([]);
const [map, setMap] = useState<OlMap>();
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
@@ -143,7 +173,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r]));
return junctionData.map((j) => {
const record = nodeMap.get(j.id);
return record ? { ...j, [junctionText]: record.value } : j;
let val = record ? record.value : undefined;
// 在这合并时将实际需水量从 LPS 转换为大写表示
if (val !== undefined && junctionText === "actualdemand") {
val = toM3h(val, "lps");
}
return record ? { ...j, [junctionText]: val } : j;
});
}, [junctionData, currentJunctionCalData, junctionText]);
@@ -153,9 +188,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const record = linkMap.get(p.id);
if (!record) return p;
const isFlow = pipeText === "flow";
let val = record.value;
if (val !== undefined && isFlow) {
val = toM3h(val, "lps");
}
return {
...p,
[pipeText]: isFlow ? Math.abs(record.value) : record.value,
[pipeText]: isFlow ? Math.abs(val) : val,
flowFlag: isFlow && record.value < 0 ? -1 : 1,
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
};
@@ -169,20 +208,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[number, number] | undefined
>();
// 防抖更新函数
const debouncedUpdateData = useRef(
debounce(() => {
if (tileJunctionDataBuffer.current.length > 0) {
setJunctionData(tileJunctionDataBuffer.current);
tileJunctionDataBuffer.current = [];
}
if (tilePipeDataBuffer.current.length > 0) {
setPipeData(tilePipeDataBuffer.current);
tilePipeDataBuffer.current = [];
}
}, 100),
);
const setJunctionData = (newData: any[]) => {
const uniqueNewData = newData.filter((item) => {
if (!item || !item.id) return false;
@@ -214,6 +239,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
});
}
};
const setPipeData = (newData: any[]) => {
const uniqueNewData = newData.filter((item) => {
if (!item || !item.id) return false;
@@ -245,6 +271,28 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
});
}
};
const debouncedUpdateDataRef = useRef<DebouncedFunction<() => void> | null>(
null,
);
useEffect(() => {
debouncedUpdateDataRef.current = debounce(() => {
if (tileJunctionDataBuffer.current.length > 0) {
setJunctionData(tileJunctionDataBuffer.current);
tileJunctionDataBuffer.current = [];
}
if (tilePipeDataBuffer.current.length > 0) {
setPipeData(tilePipeDataBuffer.current);
tilePipeDataBuffer.current = [];
}
}, 100);
return () => {
debouncedUpdateDataRef.current?.cancel();
debouncedUpdateDataRef.current = null;
};
}, []);
// 配置地图数据源、图层和样式
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
// 定义 SCADA 图层的样式函数,根据 type 字段选择不同图标
@@ -459,7 +507,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const scadaLayer = new VectorLayer({
source: scadaSource,
style: scadaStyle,
// extent: extent, // 设置图层范围
extent: MAP_EXTENT, // 设置图层范围
maxZoom: 24,
minZoom: 11,
properties: {
@@ -470,16 +518,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
},
});
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
useEffect(() => {
if (!mapRef.current) return;
if (!canvasRef.current) {
return;
}
isDisposingRef.current = false;
const addTimeout = (callback: () => void, delay: number) => {
const timerId = window.setTimeout(() => {
pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter(
(id) => id !== timerId,
);
if (isDisposingRef.current) return;
callback();
}, delay);
pendingTimeoutsRef.current.push(timerId);
return timerId;
};
const clearPendingTimeouts = () => {
pendingTimeoutsRef.current.forEach((id) => clearTimeout(id));
pendingTimeoutsRef.current = [];
};
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
junctionSource.on("tileloadend", (event) => {
const handleJunctionTileLoadEnd = (event: any) => {
if (isDisposingRef.current) return;
try {
if (event.tile instanceof VectorTile) {
const renderFeatures = event.tile.getFeatures();
const data = new Map();
renderFeatures.forEach((renderFeature) => {
renderFeatures.forEach((renderFeature: any) => {
const props = renderFeature.getProperties();
const featureId = props.id;
if (featureId && !junctionDataIds.current.has(featureId)) {
@@ -502,20 +574,21 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
uniqueData.forEach((item) =>
tileJunctionDataBuffer.current.push(item),
);
debouncedUpdateData.current();
debouncedUpdateDataRef.current?.();
}
}
} catch (error) {
console.error("Junction tile load error:", error);
}
});
pipeSource.on("tileloadend", (event) => {
};
const handlePipeTileLoadEnd = (event: any) => {
if (isDisposingRef.current) return;
try {
if (event.tile instanceof VectorTile) {
const renderFeatures = event.tile.getFeatures();
const data = new Map();
renderFeatures.forEach((renderFeature) => {
renderFeatures.forEach((renderFeature: any) => {
try {
const props = renderFeature.getProperties();
const featureId = props.id;
@@ -582,13 +655,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const uniqueData = Array.from(data.values());
if (uniqueData.length > 0) {
uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item));
debouncedUpdateData.current();
debouncedUpdateDataRef.current?.();
}
}
} catch (error) {
console.error("Pipe tile load error:", error);
}
});
};
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
// 监听 junctionsLayer 的 visible 变化
const handleJunctionVisibilityChange = () => {
const isVisible = junctionsLayer.getVisible();
@@ -702,6 +777,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
}
// 持久化视图(中心点 + 缩放),防抖写入 localStorage
const persistView = debounce(() => {
if (isDisposingRef.current) return;
try {
const view = map.getView();
const center = view.getCenter();
@@ -719,7 +795,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 监听缩放变化并持久化,同时更新 currentZoom
const handleViewChange = () => {
setTimeout(() => {
addTimeout(() => {
const zoom = map.getView().getZoom() || 0;
setCurrentZoom(zoom);
persistView();
@@ -728,7 +804,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
map.getView().on("change", handleViewChange);
// 初始化当前缩放级别并强制触发瓦片加载
setTimeout(() => {
addTimeout(() => {
const initialZoom = map.getView().getZoom() || 11;
setCurrentZoom(initialZoom);
// 强制触发地图渲染,让瓦片加载事件触发
@@ -742,11 +818,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
latitude: 0,
zoom: 1,
},
canvas: "deck-canvas",
canvas: canvasRef.current,
controller: false, // 由 OpenLayers 控制视图
layers: [],
});
const deckLayer = new DeckLayer(deck, {
const deckLayer = new DeckLayer(deck, canvasRef.current, {
name: "deckLayer",
value: "deckLayer",
});
@@ -756,18 +832,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 清理函数
return () => {
isDisposingRef.current = true;
clearPendingTimeouts();
debouncedUpdateDataRef.current?.cancel();
persistView.cancel();
junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
pipeSource.un("tileloadend", handlePipeTileLoadEnd);
map.getView().un("change", handleViewChange);
junctionsLayer.un("change:visible", handleJunctionVisibilityChange);
pipesLayer.un("change:visible", handlePipeVisibilityChange);
if (deckLayerRef.current && !deckLayerRef.current.isDisposedLayer()) {
try {
map.removeLayer(deckLayerRef.current);
} catch {
// Layer may have already been removed during teardown.
}
deckLayerRef.current.disposeDeck();
}
deckLayerRef.current = null;
setDeckLayer(undefined);
map.setTarget(undefined);
map.dispose();
deck.finalize();
};
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [MAP_WORKSPACE, MAP_EXTENT]);
// 当数据变化时,更新 deck.gl 图层
useEffect(() => {
if (isDisposingRef.current) return;
const deckLayer = deckLayerRef.current;
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
if (deckLayer.isDisposedLayer()) return;
if (!mergedJunctionData.length) return;
if (!mergedPipeData.length) return;
const junctionTextLayer = new TextLayer({
@@ -782,15 +877,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
let propPart = "";
if (showJunctionTextLayer && d[junctionText] !== undefined) {
const value = (d[junctionText] as number).toFixed(3);
// 根据属性类型添加符号前缀
const prefix =
{
pressure: "P:",
head: "H:",
quality: "Q:",
actualdemand: "D:",
}[junctionText] || "";
propPart = `${prefix}${value}`;
propPart = `${value}`;
}
if (idPart && propPart) return `${idPart} - ${propPart}`;
return idPart || propPart;
@@ -842,17 +929,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
} else {
value = Math.abs(d[pipeText] as number).toFixed(3);
}
// 根据属性类型添加符号前缀
const prefix =
{
flow: "F:",
velocity: "V:",
headloss: "HL:",
unit_headloss: "UHL:",
diameter: "D:",
friction: "FR:",
}[pipeText] || "";
propPart = `${prefix}${value}`;
propPart = `${value}`;
}
if (idPart && propPart) return `${idPart} - ${propPart}`;
return idPart || propPart;
@@ -935,6 +1012,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 控制流动动画开关
useEffect(() => {
if (isDisposingRef.current) return;
if (pipeText === "flow" && currentPipeCalData.length > 0) {
flowAnimation.current = true;
} else {
@@ -947,6 +1025,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 动画循环
const animate = () => {
if (isDisposingRef.current || deckLayer.isDisposedLayer()) return;
// 动画总时长(秒)
const animationDuration = 10;
const bufferTime = 2;
@@ -1046,7 +1125,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
<MapTools />
{children}
</div>
<canvas id="deck-canvas" />
<canvas ref={canvasRef} />
</MapContext.Provider>
</DataContext.Provider>
</>

Some files were not shown because too many files have changed in this diff Show More